]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Lp 1835035: Refactor auto_renewal back end code
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / Circulate.pm
1 package OpenILS::Application::Circ::Circulate;
2 use strict; use warnings;
3 use base 'OpenILS::Application';
4 use OpenSRF::EX qw(:try);
5 use OpenSRF::AppSession;
6 use OpenSRF::Utils::SettingsClient;
7 use OpenSRF::Utils::Logger qw(:logger);
8 use OpenSRF::Utils::Config;
9 use OpenILS::Const qw/:const/;
10 use OpenILS::Application::AppUtils;
11 use DateTime;
12 my $U = "OpenILS::Application::AppUtils";
13
14 my %scripts;
15 my $booking_status;
16 my $opac_renewal_use_circ_lib;
17 my $desk_renewal_use_circ_lib;
18
19 sub determine_booking_status {
20     unless (defined $booking_status) {
21         my $router_name = OpenSRF::Utils::Config
22             ->current
23             ->bootstrap
24             ->router_name || 'router';
25
26         my $ses = create OpenSRF::AppSession($router_name);
27         $booking_status = grep {$_ eq "open-ils.booking"} @{
28             $ses->request("opensrf.router.info.class.list")->gather(1)
29         };
30         $ses->disconnect;
31         $logger->info("booking status: " . ($booking_status ? "on" : "off"));
32     }
33
34     return $booking_status;
35 }
36
37
38 my $MK_ENV_FLESH = { 
39     flesh => 2, 
40     flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
41 };
42
43 # table of cases where suppressing a system-generated copy alerts
44 # should generate an override of an old-style event
45 my %COPY_ALERT_OVERRIDES = (
46     "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
47     "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
48     "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
49     "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
50     "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
51     "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
52     "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
53 );
54
55 sub initialize {}
56
57 __PACKAGE__->register_method(
58     method  => "run_method",
59     api_name    => "open-ils.circ.checkout.permit",
60     notes       => q/
61         Determines if the given checkout can occur
62         @param authtoken The login session key
63         @param params A trailing hash of named params including 
64             barcode : The copy barcode, 
65             patron : The patron the checkout is occurring for, 
66             renew : true or false - whether or not this is a renewal
67         @return The event that occurred during the permit check.  
68     /);
69
70
71 __PACKAGE__->register_method (
72     method      => 'run_method',
73     api_name        => 'open-ils.circ.checkout.permit.override',
74     signature   => q/@see open-ils.circ.checkout.permit/,
75 );
76
77
78 __PACKAGE__->register_method(
79     method  => "run_method",
80     api_name    => "open-ils.circ.checkout",
81     notes => q/
82         Checks out an item
83         @param authtoken The login session key
84         @param params A named hash of params including:
85             copy            The copy object
86             barcode     If no copy is provided, the copy is retrieved via barcode
87             copyid      If no copy or barcode is provide, the copy id will be use
88             patron      The patron's id
89             noncat      True if this is a circulation for a non-cataloted item
90             noncat_type The non-cataloged type id
91             noncat_circ_lib The location for the noncat circ.  
92             precat      The item has yet to be cataloged
93             dummy_title The temporary title of the pre-cataloded item
94             dummy_author The temporary authr of the pre-cataloded item
95                 Default is the home org of the staff member
96         @return The SUCCESS event on success, any other event depending on the error
97     /);
98
99 __PACKAGE__->register_method(
100     method  => "run_method",
101     api_name    => "open-ils.circ.checkin",
102     argc        => 2,
103     signature   => q/
104         Generic super-method for handling all copies
105         @param authtoken The login session key
106         @param params Hash of named parameters including:
107             barcode - The copy barcode
108             force   - If true, copies in bad statuses will be checked in and give good statuses
109             noop    - don't capture holds or put items into transit
110             void_overdues - void all overdues for the circulation (aka amnesty)
111             ...
112     /
113 );
114
115 __PACKAGE__->register_method(
116     method    => "run_method",
117     api_name  => "open-ils.circ.checkin.override",
118     signature => q/@see open-ils.circ.checkin/
119 );
120
121 __PACKAGE__->register_method(
122     method    => "run_method",
123     api_name  => "open-ils.circ.renew.override",
124     signature => q/@see open-ils.circ.renew/,
125 );
126
127 __PACKAGE__->register_method(
128     method    => "run_method",
129     api_name  => "open-ils.circ.renew.auto",
130     signature => q/@see open-ils.circ.renew/,
131     notes     => q/
132     The open-ils.circ.renew.auto API is deprecated.  Please use the
133     auto_renew => 1 option to open-ils.circ.renew, instead.
134     /
135 );
136
137 __PACKAGE__->register_method(
138     method  => "run_method",
139     api_name    => "open-ils.circ.renew",
140     notes       => <<"    NOTES");
141     PARAMS( authtoken, circ => circ_id );
142     open-ils.circ.renew(login_session, circ_object);
143     Renews the provided circulation.  login_session is the requestor of the
144     renewal and if the logged in user is not the same as circ->usr, then
145     the logged in user must have RENEW_CIRC permissions.
146     NOTES
147
148 __PACKAGE__->register_method(
149     method   => "run_method",
150     api_name => "open-ils.circ.checkout.full"
151 );
152 __PACKAGE__->register_method(
153     method   => "run_method",
154     api_name => "open-ils.circ.checkout.full.override"
155 );
156 __PACKAGE__->register_method(
157     method   => "run_method",
158     api_name => "open-ils.circ.reservation.pickup"
159 );
160 __PACKAGE__->register_method(
161     method   => "run_method",
162     api_name => "open-ils.circ.reservation.return"
163 );
164 __PACKAGE__->register_method(
165     method   => "run_method",
166     api_name => "open-ils.circ.reservation.return.override"
167 );
168 __PACKAGE__->register_method(
169     method   => "run_method",
170     api_name => "open-ils.circ.checkout.inspect",
171     desc     => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
172 );
173
174
175 sub run_method {
176     my( $self, $conn, $auth, $args ) = @_;
177     translate_legacy_args($args);
178     $args->{override_args} = { all => 1 } unless defined $args->{override_args};
179     $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
180     my $api = $self->api_name;
181
182     my $circulator = 
183         OpenILS::Application::Circ::Circulator->new($auth, %$args);
184
185     return circ_events($circulator) if $circulator->bail_out;
186
187     $circulator->use_booking(determine_booking_status());
188
189     # --------------------------------------------------------------------------
190     # First, check for a booking transit, as the barcode may not be a copy
191     # barcode, but a resource barcode, and nothing else in here will work
192     # --------------------------------------------------------------------------
193
194     if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
195         my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
196         if (@$resources) { # yes!
197
198             my $res_id_list = [ map { $_->id } @$resources ];
199             my $transit = $circulator->editor->search_action_reservation_transit_copy(
200                 [
201                     { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
202                     { order_by => { artc => 'source_send_time' }, limit => 1 }
203                 ]
204             )->[0]; # Any transit for this barcode?
205
206             if ($transit) { # yes! unwrap it.
207
208                 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
209                 my $res_type    = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
210
211                 my $success_event = new OpenILS::Event(
212                     "SUCCESS", "payload" => {"reservation" => $reservation}
213                 );
214                 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
215                     if (my $copy = $circulator->editor->search_asset_copy([
216                         { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
217                     ])->[0]) { # got a copy
218                         $copy->status( $transit->copy_status );
219                         $copy->editor($circulator->editor->requestor->id);
220                         $copy->edit_date('now');
221                         $circulator->editor->update_asset_copy($copy);
222                         $success_event->{"payload"}->{"record"} =
223                             $U->record_to_mvr($copy->call_number->record);
224                         $success_event->{"payload"}->{"volume"} = $copy->call_number;
225                         $copy->call_number($copy->call_number->id);
226                         $success_event->{"payload"}->{"copy"} = $copy;
227                     }
228                 }
229
230                 $transit->dest_recv_time('now');
231                 $circulator->editor->update_action_reservation_transit_copy( $transit );
232
233                 $circulator->editor->commit;
234                 # Formerly this branch just stopped here. Argh!
235                 $conn->respond_complete($success_event);
236                 return;
237             }
238         }
239     }
240
241     if ($circulator->use_booking) {
242         $circulator->is_res_checkin($circulator->is_checkin(1))
243             if $api =~ /reservation.return/ or (
244                 $api =~ /checkin/ and $circulator->seems_like_reservation()
245             );
246
247         $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
248     }
249
250     $circulator->is_renewal(1) if $api =~ /renew/;
251     $circulator->auto_renewal(1) if $api =~ /renew.auto/;
252     $circulator->is_checkin(1) if $api =~ /checkin/;
253     $circulator->is_checkout(1) if $api =~ /checkout/;
254     $circulator->override(1) if $api =~ /override/o;
255
256     $circulator->mk_env();
257     $circulator->noop(1) if $circulator->claims_never_checked_out;
258
259     return circ_events($circulator) if $circulator->bail_out;
260
261     if( $api =~ /checkout\.permit/ ) {
262         $circulator->do_permit();
263
264     } elsif( $api =~ /checkout.full/ ) {
265
266         # requesting a precat checkout implies that any required
267         # overrides have been performed.  Go ahead and re-override.
268         $circulator->skip_permit_key(1);
269         $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
270         $circulator->do_permit();
271         $circulator->is_checkout(1);
272         unless( $circulator->bail_out ) {
273             $circulator->events([]);
274             $circulator->do_checkout();
275         }
276
277     } elsif( $circulator->is_res_checkout ) {
278         $circulator->do_reservation_pickup();
279
280     } elsif( $api =~ /inspect/ ) {
281         my $data = $circulator->do_inspect();
282         $circulator->editor->rollback;
283         return $data;
284
285     } elsif( $api =~ /checkout/ ) {
286         $circulator->do_checkout();
287
288     } elsif( $circulator->is_res_checkin ) {
289         $circulator->do_reservation_return();
290         $circulator->do_checkin() if ($circulator->copy());
291     } elsif( $api =~ /checkin/ ) {
292         $circulator->do_checkin();
293
294     } elsif( $api =~ /renew/ ) {
295         $circulator->do_renew($api);
296     }
297
298     if( $circulator->bail_out ) {
299
300         my @ee;
301         # make sure no success event accidentally slip in
302         $circulator->events(
303             [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
304
305         # Log the events
306         my @e = @{$circulator->events};
307         push( @ee, $_->{textcode} ) for @e;
308         $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
309
310         $circulator->editor->rollback;
311
312     } else {
313
314         # checkin and reservation return can result in modifications to
315         # actor.usr.claims_never_checked_out_count without also modifying
316         # actor.last_xact_id.  Perform a no-op update on the patron to
317         # force an update to last_xact_id.
318         if ($circulator->claims_never_checked_out && $circulator->patron) {
319             $circulator->editor->update_actor_user(
320                 $circulator->editor->retrieve_actor_user($circulator->patron->id))
321                 or return $circulator->editor->die_event;
322         }
323
324         $circulator->editor->commit;
325     }
326     
327     $conn->respond_complete(circ_events($circulator));
328
329     return undef if $circulator->bail_out;
330
331     $circulator->do_hold_notify($circulator->notify_hold)
332         if $circulator->notify_hold;
333     $circulator->retarget_holds if $circulator->retarget;
334     $circulator->append_reading_list;
335     $circulator->make_trigger_events;
336     
337     return undef;
338 }
339
340 sub circ_events {
341     my $circ = shift;
342     my @e = @{$circ->events};
343     # if we have multiple events, SUCCESS should not be one of them;
344     @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
345     return (@e == 1) ? $e[0] : \@e;
346 }
347
348
349 sub translate_legacy_args {
350     my $args = shift;
351
352     if( $$args{barcode} ) {
353         $$args{copy_barcode} = $$args{barcode};
354         delete $$args{barcode};
355     }
356
357     if( $$args{copyid} ) {
358         $$args{copy_id} = $$args{copyid};
359         delete $$args{copyid};
360     }
361
362     if( $$args{patronid} ) {
363         $$args{patron_id} = $$args{patronid};
364         delete $$args{patronid};
365     }
366
367     if( $$args{patron} and !ref($$args{patron}) ) {
368         $$args{patron_id} = $$args{patron};
369         delete $$args{patron};
370     }
371
372
373     if( $$args{noncat} ) {
374         $$args{is_noncat} = $$args{noncat};
375         delete $$args{noncat};
376     }
377
378     if( $$args{precat} ) {
379         $$args{is_precat} = $$args{request_precat} = $$args{precat};
380         delete $$args{precat};
381     }
382 }
383
384
385
386 # --------------------------------------------------------------------------
387 # This package actually manages all of the circulation logic
388 # --------------------------------------------------------------------------
389 package OpenILS::Application::Circ::Circulator;
390 use strict; use warnings;
391 use vars q/$AUTOLOAD/;
392 use DateTime;
393 use OpenILS::Utils::Fieldmapper;
394 use OpenSRF::Utils::Cache;
395 use Digest::MD5 qw(md5_hex);
396 use DateTime::Format::ISO8601;
397 use OpenILS::Utils::PermitHold;
398 use OpenILS::Utils::DateTime qw/:datetime/;
399 use OpenSRF::Utils::SettingsClient;
400 use OpenILS::Application::Circ::Holds;
401 use OpenILS::Application::Circ::Transit;
402 use OpenSRF::Utils::Logger qw(:logger);
403 use OpenILS::Utils::CStoreEditor qw/:funcs/;
404 use OpenILS::Const qw/:const/;
405 use OpenILS::Utils::Penalty;
406 use OpenILS::Application::Circ::CircCommon;
407 use Time::Local;
408
409 my $CC = "OpenILS::Application::Circ::CircCommon";
410 my $holdcode    = "OpenILS::Application::Circ::Holds";
411 my $transcode   = "OpenILS::Application::Circ::Transit";
412 my %user_groups;
413
414 sub DESTROY { }
415
416
417 # --------------------------------------------------------------------------
418 # Add a pile of automagic getter/setter methods
419 # --------------------------------------------------------------------------
420 my @AUTOLOAD_FIELDS = qw/
421     notify_hold
422     remote_hold
423     backdate
424     reservation
425     do_inventory_update
426     latest_inventory
427     copy
428     copy_id
429     copy_barcode
430     new_copy_alerts
431     user_copy_alerts
432     system_copy_alerts
433     overrides_per_copy_alerts
434     next_copy_status
435     copy_state
436     patron
437     patron_id
438     patron_barcode
439     volume
440     title
441     is_renewal
442     is_checkout
443     is_res_checkout
444     is_precat
445     is_noncat
446     request_precat
447     is_checkin
448     is_res_checkin
449     noncat_type
450     editor
451     events
452     cache_handle
453     override
454     circ_permit_patron
455     circ_permit_copy
456     circ_duration
457     circ_recurring_fines
458     circ_max_fines
459     circ_permit_renew
460     circ
461     transit
462     hold
463     permit_key
464     noncat_circ_lib
465     noncat_count
466     checkout_time
467     dummy_title
468     dummy_author
469     dummy_isbn
470     circ_modifier
471     circ_lib
472     barcode
473     duration_level
474     recurring_fines_level
475     duration_rule
476     recurring_fines_rule
477     max_fine_rule
478     renewal_remaining
479     auto_renewal_remaining
480     hard_due_date
481     due_date
482     fulfilled_holds
483     transit
484     checkin_changed
485     force
486     permit_override
487     pending_checkouts
488     cancelled_hold_transit
489     opac_renewal
490     phone_renewal
491     desk_renewal
492     sip_renewal
493     auto_renewal
494     retarget
495     matrix_test_result
496     circ_matrix_matchpoint
497     circ_test_success
498     is_deposit
499     is_rental
500     deposit_billing
501     rental_billing
502     capture
503     noop
504     void_overdues
505     parent_circ
506     return_patron
507     claims_never_checked_out
508     skip_permit_key
509     skip_deposit_fee
510     skip_rental_fee
511     use_booking
512     clear_expired
513     retarget_mode
514     hold_as_transit
515     fake_hold_dest
516     limit_groups
517     override_args
518     checkout_is_for_hold
519     manual_float
520     dont_change_lost_zero
521     lost_bill_options
522     needs_lost_bill_handling
523 /;
524
525
526 sub AUTOLOAD {
527     my $self = shift;
528     my $type = ref($self) or die "$self is not an object";
529     my $data = shift;
530     my $name = $AUTOLOAD;
531     $name =~ s/.*://o;   
532
533     unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
534         $logger->error("circulator: $type: invalid autoload field: $name");
535         die "$type: invalid autoload field: $name\n" 
536     }
537
538     {
539         no strict 'refs';
540         *{"${type}::${name}"} = sub {
541             my $s = shift;
542             my $v = shift;
543             $s->{$name} = $v if defined $v;
544             return $s->{$name};
545         }
546     }
547     return $self->$name($data);
548 }
549
550
551 sub new {
552     my( $class, $auth, %args ) = @_;
553     $class = ref($class) || $class;
554     my $self = bless( {}, $class );
555
556     $self->events([]);
557     $self->editor(new_editor(xact => 1, authtoken => $auth));
558
559     unless( $self->editor->checkauth ) {
560         $self->bail_on_events($self->editor->event);
561         return $self;
562     }
563
564     $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
565
566     $self->$_($args{$_}) for keys %args;
567
568     $self->circ_lib(
569         ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
570
571     # if this is a renewal, default to desk_renewal
572     $self->desk_renewal(1) unless
573         $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
574         or $self->auto_renewal;
575
576     $self->capture('') unless $self->capture;
577
578     unless(%user_groups) {
579         my $gps = $self->editor->retrieve_all_permission_grp_tree;
580         %user_groups = map { $_->id => $_ } @$gps;
581     }
582
583     return $self;
584 }
585
586
587 # --------------------------------------------------------------------------
588 # True if we should discontinue processing
589 # --------------------------------------------------------------------------
590 sub bail_out {
591     my( $self, $bool ) = @_;
592     if( defined $bool ) {
593         $logger->info("circulator: BAILING OUT") if $bool;
594         $self->{bail_out} = $bool;
595     }
596     return $self->{bail_out};
597 }
598
599
600 sub push_events {
601     my( $self, @evts ) = @_;
602     for my $e (@evts) {
603         next unless $e;
604         $e->{payload} = $self->copy if 
605               ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
606
607         $logger->info("circulator: pushing event ".$e->{textcode});
608         push( @{$self->events}, $e ) unless
609             grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
610     }
611 }
612
613 sub mk_permit_key {
614     my $self = shift;
615     return '' if $self->skip_permit_key;
616     my $key = md5_hex( time() . rand() . "$$" );
617     $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
618     return $self->permit_key($key);
619 }
620
621 sub check_permit_key {
622     my $self = shift;
623     return 1 if $self->skip_permit_key;
624     my $key = $self->permit_key;
625     return 0 unless $key;
626     my $k = "oils_permit_key_$key";
627     my $one = $self->cache_handle->get_cache($k);
628     $self->cache_handle->delete_cache($k);
629     return ($one) ? 1 : 0;
630 }
631
632 sub seems_like_reservation {
633     my $self = shift;
634
635     # Some words about the following method:
636     # 1) It requires the VIEW_USER permission, but that's not an
637     # issue, right, since all staff should have that?
638     # 2) It returns only one reservation at a time, even if an item can be
639     # and is currently overbooked.  Hmmm....
640     my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
641     my $result = $booking_ses->request(
642         "open-ils.booking.reservations.by_returnable_resource_barcode",
643         $self->editor->authtoken,
644         $self->copy_barcode
645     )->gather(1);
646     $booking_ses->disconnect;
647
648     return $self->bail_on_events($result) if defined $U->event_code($result);
649
650     if (@$result > 0) {
651         $self->reservation(shift @$result);
652         return 1;
653     } else {
654         return 0;
655     }
656
657 }
658
659 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
660 sub save_trimmed_copy {
661     my ($self, $copy) = @_;
662
663     $self->copy($copy);
664     $self->volume($copy->call_number);
665     $self->title($self->volume->record);
666     $self->copy->call_number($self->volume->id);
667     $self->volume->record($self->title->id);
668     $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
669     if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
670         $self->is_deposit(1) if $U->is_true($self->copy->deposit);
671         $self->is_rental(1) unless $U->is_true($self->copy->deposit);
672     }
673 }
674
675 sub collect_user_copy_alerts {
676     my $self = shift;
677     my $e = $self->editor;
678
679     if($self->copy) {
680         my $alerts = $e->search_asset_copy_alert([
681             {copy => $self->copy->id, ack_time => undef},
682             {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
683         ]);
684         if (ref $alerts eq "ARRAY") {
685             $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
686                 $self->copy->id);
687             $self->user_copy_alerts($alerts);
688         }
689     }
690 }
691
692 sub filter_user_copy_alerts {
693     my $self = shift;
694
695     my $e = $self->editor;
696
697     if(my $alerts = $self->user_copy_alerts) {
698
699         my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
700         my $suppressions = $e->search_actor_copy_alert_suppress(
701             {org => $suppress_orgs}
702         );
703
704         my @final_alerts;
705         foreach my $a (@$alerts) {
706             # filter on event type
707             if (defined $a->alert_type) {
708                 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
709                 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
710                 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
711                 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
712             }
713
714             # filter on suppression
715             next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
716
717             # filter on "only at circ lib"
718             if (defined $a->alert_type->at_circ) {
719                 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
720                     $self->copy->circ_lib->id : $self->copy->circ_lib;
721                 my $orgs = $U->get_org_descendants($copy_circ_lib);
722
723                 if ($U->is_true($a->alert_type->invert_location)) {
724                     next if (grep {$_ == $self->circ_lib} @$orgs);
725                 } else {
726                     next unless (grep {$_ == $self->circ_lib} @$orgs);
727                 }
728             }
729
730             # filter on "only at owning lib"
731             if (defined $a->alert_type->at_owning) {
732                 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
733                     $self->volume->owning_lib->id : $self->volume->owning_lib;
734                 my $orgs = $U->get_org_descendants($copy_owning_lib);
735
736                 if ($U->is_true($a->alert_type->invert_location)) {
737                     next if (grep {$_ == $self->circ_lib} @$orgs);
738                 } else {
739                     next unless (grep {$_ == $self->circ_lib} @$orgs);
740                 }
741             }
742
743             $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
744
745             push @final_alerts, $a;
746         }
747
748         $self->user_copy_alerts(\@final_alerts);
749     }
750 }
751
752 sub generate_system_copy_alerts {
753     my $self = shift;
754     return unless($self->copy);
755
756     # don't create system copy alerts if the copy
757     # is in a normal state; we're assuming that there's
758     # never a need to generate a popup for each and every
759     # checkin or checkout of normal items. If this assumption
760     # proves false, then we'll need to add a way to explicitly specify
761     # that a copy alert type should never generate a system copy alert
762     return if $self->copy_state eq 'NORMAL';
763
764     my $e = $self->editor;
765
766     my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
767     my $suppressions = $e->search_actor_copy_alert_suppress(
768         {org => $suppress_orgs}
769     );
770
771     # events we care about ...
772     my $event = [];
773     push(@$event, 'CHECKIN') if $self->is_checkin;
774     push(@$event, 'CHECKOUT') if $self->is_checkout;
775     return unless scalar(@$event);
776
777     my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
778     my $alert_types = $e->search_config_copy_alert_type({
779         active    => 't',
780         scope_org => $alert_orgs,
781         event     => $event,
782         state => $self->copy_state,
783         '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
784     });
785
786     my @final_types;
787     foreach my $a (@$alert_types) {
788         # filter on "only at circ lib"
789         if (defined $a->at_circ) {
790             my $copy_circ_lib = (ref $self->copy->circ_lib) ?
791                 $self->copy->circ_lib->id : $self->copy->circ_lib;
792             my $orgs = $U->get_org_descendants($copy_circ_lib);
793
794             if ($U->is_true($a->invert_location)) {
795                 next if (grep {$_ == $self->circ_lib} @$orgs);
796             } else {
797                 next unless (grep {$_ == $self->circ_lib} @$orgs);
798             }
799         }
800
801         # filter on "only at owning lib"
802         if (defined $a->at_owning) {
803             my $copy_owning_lib = (ref $self->volume->owning_lib) ?
804                 $self->volume->owning_lib->id : $self->volume->owning_lib;
805             my $orgs = $U->get_org_descendants($copy_owning_lib);
806
807             if ($U->is_true($a->invert_location)) {
808                 next if (grep {$_ == $self->circ_lib} @$orgs);
809             } else {
810                 next unless (grep {$_ == $self->circ_lib} @$orgs);
811             }
812         }
813
814         push @final_types, $a;
815     }
816
817     if (@final_types) {
818         $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
819             $self->copy->id);
820     }
821
822     my @alerts;
823     
824     # keep track of conditions corresponding to suppressed
825     # system alerts, as these may be used to overridee
826     # certain old-style-events
827     my %auto_override_conditions = ();
828     foreach my $t (@final_types) {
829         if ($t->next_status) {
830             if (grep { $t->id == $_->alert_type } @$suppressions) {
831                 $t->next_status([]);
832             } else {
833                 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
834             }
835         }
836
837         my $alert = new Fieldmapper::asset::copy_alert ();
838         $alert->alert_type($t->id);
839         $alert->copy($self->copy->id);
840         $alert->temp(1);
841         $alert->create_staff($e->requestor->id);
842         $alert->create_time('now');
843         $alert->ack_staff($e->requestor->id);
844         $alert->ack_time('now');
845
846         $alert = $e->create_asset_copy_alert($alert);
847
848         next unless $alert;
849
850         $alert->alert_type($t->clone);
851
852         push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
853         if (grep {$_->alert_type == $t->id} @$suppressions) {
854             $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
855         }
856         push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
857     }
858
859     $self->system_copy_alerts(\@alerts);
860     $self->overrides_per_copy_alerts(\%auto_override_conditions);
861 }
862
863 sub add_overrides_from_system_copy_alerts {
864     my $self = shift;
865     my $e = $self->editor;
866
867     foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
868         if (exists $COPY_ALERT_OVERRIDES{$condition}) {
869             $self->override(1);
870             push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
871             # special handling for long-overdue and lost checkouts
872             if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
873                 my $state = (split /\t/, $condition, -1)[0];
874                 my $setting;
875                 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
876                     $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
877                 } elsif ($state eq 'LONGOVERDUE') {
878                     $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
879                 } else {
880                     next;
881                 }
882                 my $forgive = $U->ou_ancestor_setting_value(
883                     $self->circ_lib, $setting, $e
884                 );
885                 if ($U->is_true($forgive)) {
886                     $self->void_overdues(1);
887                 }
888                 $self->noop(1); # do not attempt transits, just check it in
889                 $self->do_checkin();
890             }
891         }
892     }
893 }
894
895 sub mk_env {
896     my $self = shift;
897     my $e = $self->editor;
898
899     $self->next_copy_status([]) unless (defined $self->next_copy_status);
900     $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
901
902     # --------------------------------------------------------------------------
903     # Grab the fleshed copy
904     # --------------------------------------------------------------------------
905     unless($self->is_noncat) {
906         my $copy;
907         if($self->copy_id) {
908             $copy = $e->retrieve_asset_copy(
909                 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
910     
911         } elsif( $self->copy_barcode ) {
912     
913             $copy = $e->search_asset_copy(
914                 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
915         } elsif( $self->reservation ) {
916             my $res = $e->json_query(
917                 {
918                     "select" => {"acp" => ["id"]},
919                     "from" => {
920                         "acp" => {
921                             "brsrc" => {
922                                 "fkey" => "barcode",
923                                 "field" => "barcode",
924                                 "join" => {
925                                     "bresv" => {
926                                         "fkey" => "id",
927                                         "field" => "current_resource"
928                                     }
929                                 }
930                             }
931                         }
932                     },
933                     "where" => {
934                         deleted => 'f',
935                         "+bresv" => {
936                             "id" => (ref $self->reservation) ?
937                                 $self->reservation->id : $self->reservation
938                         }
939                     }
940                 }
941             );
942             if (ref $res eq "ARRAY" and scalar @$res) {
943                 $logger->info("circulator: mapped reservation " .
944                     $self->reservation . " to copy " . $res->[0]->{"id"});
945                 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
946             }
947         }
948     
949         if($copy) {
950             $self->save_trimmed_copy($copy);
951
952             # alerts!
953             $self->copy_state(
954                 $e->json_query(
955                     {from => ['asset.copy_state', $copy->id]}
956                 )->[0]{'asset.copy_state'}
957             );
958
959             $self->generate_system_copy_alerts;
960             $self->add_overrides_from_system_copy_alerts;
961             $self->collect_user_copy_alerts;
962             $self->filter_user_copy_alerts;
963
964         } else {
965             # We can't renew if there is no copy
966             return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
967                 if $self->is_renewal;
968             $self->is_precat(1);
969         }
970     }
971
972     # --------------------------------------------------------------------------
973     # Grab the patron
974     # --------------------------------------------------------------------------
975     my $patron;
976     my $flesh = {
977         flesh => 1,
978         flesh_fields => {au => [ qw/ card / ]}
979     };
980
981     if( $self->patron_id ) {
982         $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
983             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
984
985     } elsif( $self->patron_barcode ) {
986
987         # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
988         my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0] 
989             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
990
991         $patron = $e->retrieve_actor_user($card->usr)
992             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
993
994         # Use the card we looked up, not the patron's primary, for card active checks
995         $patron->card($card);
996
997     } else {
998         if( my $copy = $self->copy ) {
999
1000             $flesh->{flesh} = 2;
1001             $flesh->{flesh_fields}->{circ} = ['usr'];
1002
1003             my $circ = $e->search_action_circulation([
1004                 {target_copy => $copy->id, checkin_time => undef}, $flesh
1005             ])->[0];
1006
1007             if($circ) {
1008                 $patron = $circ->usr;
1009                 $circ->usr($patron->id); # de-flesh for consistency
1010                 $self->circ($circ); 
1011             }
1012         }
1013     }
1014
1015     return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1016         unless $self->patron($patron) or $self->is_checkin;
1017
1018     unless($self->is_checkin) {
1019
1020         # Check for inactivity and patron reg. expiration
1021
1022         $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1023             unless $U->is_true($patron->active);
1024     
1025         $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1026             unless $U->is_true($patron->card->active);
1027     
1028         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1029             clean_ISO8601($patron->expire_date));
1030     
1031         $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1032             if( CORE::time > $expire->epoch ) ;
1033     }
1034 }
1035
1036
1037 # --------------------------------------------------------------------------
1038 # Does the circ permit work
1039 # --------------------------------------------------------------------------
1040 sub do_permit {
1041     my $self = shift;
1042
1043     $self->log_me("do_permit()");
1044
1045     unless( $self->editor->requestor->id == $self->patron->id ) {
1046         return $self->bail_on_events($self->editor->event)
1047             unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1048     }
1049
1050     $self->check_captured_holds();
1051     $self->do_copy_checks();
1052     return if $self->bail_out;
1053     $self->run_patron_permit_scripts();
1054     $self->run_copy_permit_scripts() 
1055         unless $self->is_precat or $self->is_noncat;
1056     $self->check_item_deposit_events();
1057     $self->override_events();
1058     return if $self->bail_out;
1059
1060     if($self->is_precat and not $self->request_precat) {
1061         $self->push_events(
1062             OpenILS::Event->new(
1063                 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1064         return $self->bail_out(1) unless $self->is_renewal;
1065     }
1066
1067     $self->push_events(
1068         OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1069 }
1070
1071 sub check_item_deposit_events {
1072     my $self = shift;
1073     $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy)) 
1074         if $self->is_deposit and not $self->is_deposit_exempt;
1075     $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy)) 
1076         if $self->is_rental and not $self->is_rental_exempt;
1077 }
1078
1079 # returns true if the user is not required to pay deposits
1080 sub is_deposit_exempt {
1081     my $self = shift;
1082     my $pid = (ref $self->patron->profile) ?
1083         $self->patron->profile->id : $self->patron->profile;
1084     my $groups = $U->ou_ancestor_setting_value(
1085         $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1086     for my $grp (@$groups) {
1087         return 1 if $self->is_group_descendant($grp, $pid);
1088     }
1089     return 0;
1090 }
1091
1092 # returns true if the user is not required to pay rental fees
1093 sub is_rental_exempt {
1094     my $self = shift;
1095     my $pid = (ref $self->patron->profile) ?
1096         $self->patron->profile->id : $self->patron->profile;
1097     my $groups = $U->ou_ancestor_setting_value(
1098         $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1099     for my $grp (@$groups) {
1100         return 1 if $self->is_group_descendant($grp, $pid);
1101     }
1102     return 0;
1103 }
1104
1105 sub is_group_descendant {
1106     my($self, $p_id, $c_id) = @_;
1107     return 0 unless defined $p_id and defined $c_id;
1108     return 1 if $c_id == $p_id;
1109     while(my $grp = $user_groups{$c_id}) {
1110         $c_id = $grp->parent;
1111         return 0 unless defined $c_id;
1112         return 1 if $c_id == $p_id;
1113     }
1114     return 0;
1115 }
1116
1117 sub check_captured_holds {
1118     my $self    = shift;
1119     my $copy    = $self->copy;
1120     my $patron  = $self->patron;
1121
1122     return undef unless $copy;
1123
1124     my $s = $U->copy_status($copy->status)->id;
1125     return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1126     $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1127
1128     # Item is on the holds shelf, make sure it's going to the right person
1129     my $hold = $self->editor->search_action_hold_request(
1130         [
1131             { 
1132                 current_copy        => $copy->id , 
1133                 capture_time        => { '!=' => undef },
1134                 cancel_time         => undef, 
1135                 fulfillment_time    => undef 
1136             },
1137             { limit => 1,
1138               flesh => 1,
1139               flesh_fields => { ahr => ['usr'] }
1140             }
1141         ]
1142     )->[0];
1143
1144     if ($hold and $hold->usr->id == $patron->id) {
1145         $self->checkout_is_for_hold(1);
1146         return undef;
1147     } elsif ($hold) {
1148         my $payload;
1149         my $holdau = $hold->usr;
1150
1151         if ($holdau) {
1152             $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1153             $payload->{patron_id} = $holdau->id;
1154         } else {
1155             $payload->{patron_name} = "???";
1156         }
1157         $payload->{hold_id}     = $hold->id;
1158         $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1159                                                payload => $payload));
1160     }
1161
1162     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1163
1164 }
1165
1166
1167 sub do_copy_checks {
1168     my $self = shift;
1169     my $copy = $self->copy;
1170     return unless $copy;
1171
1172     my $stat = $U->copy_status($copy->status)->id;
1173
1174     # We cannot check out a copy if it is in-transit
1175     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1176         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1177     }
1178
1179     $self->handle_claims_returned();
1180     return if $self->bail_out;
1181
1182     # no claims returned circ was found, check if there is any open circ
1183     unless( $self->is_renewal ) {
1184
1185         my $circs = $self->editor->search_action_circulation(
1186             { target_copy => $copy->id, checkin_time => undef }
1187         );
1188
1189         if(my $old_circ = $circs->[0]) { # an open circ was found
1190
1191             my $payload = {copy => $copy};
1192
1193             if($old_circ->usr == $self->patron->id) {
1194                 
1195                 $payload->{old_circ} = $old_circ;
1196
1197                 # If there is an open circulation on the checkout item and an auto-renew 
1198                 # interval is defined, inform the caller that they should go 
1199                 # ahead and renew the item instead of warning about open circulations.
1200     
1201                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
1202                     $self->circ_lib,
1203                     'circ.checkout_auto_renew_age', 
1204                     $self->editor
1205                 );
1206
1207                 if($auto_renew_intvl) {
1208                     my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1209                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1210
1211                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1212                         $payload->{auto_renew} = 1;
1213                     }
1214                 }
1215             }
1216
1217             return $self->bail_on_events(
1218                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1219             );
1220         }
1221     }
1222 }
1223
1224 my $LEGACY_CIRC_EVENT_MAP = {
1225     'no_item' => 'ITEM_NOT_CATALOGED',
1226     'actor.usr.barred' => 'PATRON_BARRED',
1227     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
1228     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1229     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1230     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1231     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
1232     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
1233     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1234     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1235     'config.circ_matrix_test.total_copy_hold_ratio' => 
1236         'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1237     'config.circ_matrix_test.available_copy_hold_ratio' => 
1238         'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1239 };
1240
1241
1242 # ---------------------------------------------------------------------
1243 # This pushes any patron-related events into the list but does not
1244 # set bail_out for any events
1245 # ---------------------------------------------------------------------
1246 sub run_patron_permit_scripts {
1247     my $self        = shift;
1248     my $patronid    = $self->patron->id;
1249
1250     my @allevents; 
1251
1252
1253     my $results = $self->run_indb_circ_test;
1254     unless($self->circ_test_success) {
1255         my @trimmed_results;
1256
1257         if ($self->is_noncat) {
1258             # no_item result is OK during noncat checkout
1259             @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1260
1261         } else {
1262
1263             if ($self->checkout_is_for_hold) {
1264                 # if this checkout will fulfill a hold, ignore CIRC blocks
1265                 # and rely instead on the (later-checked) FULFILL block
1266
1267                 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1268                 my $fblock_pens = $self->editor->search_config_standing_penalty(
1269                     {name => [@pen_names], block_list => {like => '%CIRC%'}});
1270
1271                 for my $res (@$results) {
1272                     my $name = $res->{fail_part} || '';
1273                     next if grep {$_->name eq $name} @$fblock_pens;
1274                     push(@trimmed_results, $res);
1275                 }
1276
1277             } else { 
1278                 # not for hold or noncat
1279                 @trimmed_results = @$results;
1280             }
1281         }
1282
1283         # update the final set of test results
1284         $self->matrix_test_result(\@trimmed_results); 
1285
1286         push @allevents, $self->matrix_test_result_events;
1287     }
1288
1289     for (@allevents) {
1290        $_->{payload} = $self->copy if 
1291              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1292     }
1293
1294     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1295
1296     $self->push_events(@allevents);
1297 }
1298
1299 sub matrix_test_result_codes {
1300     my $self = shift;
1301     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1302 }
1303
1304 sub matrix_test_result_events {
1305     my $self = shift;
1306     map {
1307         my $event = new OpenILS::Event(
1308             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1309         );
1310         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1311         $event;
1312     } (@{$self->matrix_test_result});
1313 }
1314
1315 sub run_indb_circ_test {
1316     my $self = shift;
1317     return $self->matrix_test_result if $self->matrix_test_result;
1318
1319     my $dbfunc = ($self->is_renewal) ? 
1320         'action.item_user_renew_test' : 'action.item_user_circ_test';
1321
1322     if( $self->is_precat && $self->request_precat) {
1323         $self->make_precat_copy;
1324         return if $self->bail_out;
1325     }
1326
1327     my $results = $self->editor->json_query(
1328         {   from => [
1329                 $dbfunc,
1330                 $self->circ_lib,
1331                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1332                 $self->patron->id,
1333             ]
1334         }
1335     );
1336
1337     $self->circ_test_success($U->is_true($results->[0]->{success}));
1338
1339     if(my $mp = $results->[0]->{matchpoint}) {
1340         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1341         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1342         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1343         if(defined($results->[0]->{renewals})) {
1344             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1345         }
1346         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1347         if(defined($results->[0]->{grace_period})) {
1348             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1349         }
1350         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1351         if(defined($results->[0]->{hard_due_date})) {
1352             $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1353         }
1354         # Grab the *last* response for limit_groups, where it is more likely to be filled
1355         $self->limit_groups($results->[-1]->{limit_groups});
1356     }
1357
1358     return $self->matrix_test_result($results);
1359 }
1360
1361 # ---------------------------------------------------------------------
1362 # given a use and copy, this will calculate the circulation policy
1363 # parameters.  Only works with in-db circ.
1364 # ---------------------------------------------------------------------
1365 sub do_inspect {
1366     my $self = shift;
1367
1368     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1369
1370     $self->run_indb_circ_test;
1371
1372     my $results = {
1373         circ_test_success => $self->circ_test_success,
1374         failure_events => [],
1375         failure_codes => [],
1376         matchpoint => $self->circ_matrix_matchpoint
1377     };
1378
1379     unless($self->circ_test_success) {
1380         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1381         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1382     }
1383
1384     if($self->circ_matrix_matchpoint) {
1385         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1386         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1387         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1388         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1389     
1390         my $policy = $self->get_circ_policy(
1391             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1392     
1393         $$results{$_} = $$policy{$_} for keys %$policy;
1394     }
1395
1396     return $results;
1397 }
1398
1399 # ---------------------------------------------------------------------
1400 # Loads the circ policy info for duration, recurring fine, and max
1401 # fine based on the current copy
1402 # ---------------------------------------------------------------------
1403 sub get_circ_policy {
1404     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1405
1406     my $policy = {
1407         duration_rule => $duration_rule->name,
1408         recurring_fine_rule => $recurring_fine_rule->name,
1409         max_fine_rule => $max_fine_rule->name,
1410         max_fine => $self->get_max_fine_amount($max_fine_rule),
1411         fine_interval => $recurring_fine_rule->recurrence_interval,
1412         renewal_remaining => $duration_rule->max_renewals,
1413         auto_renewal_remaining => $duration_rule->max_auto_renewals,
1414         grace_period => $recurring_fine_rule->grace_period
1415     };
1416
1417     if($hard_due_date) {
1418         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1419         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1420     }
1421     else {
1422         $policy->{duration_date_ceiling} = undef;
1423         $policy->{duration_date_ceiling_force} = undef;
1424     }
1425
1426     $policy->{duration} = $duration_rule->shrt
1427         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1428     $policy->{duration} = $duration_rule->normal
1429         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1430     $policy->{duration} = $duration_rule->extended
1431         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1432
1433     $policy->{recurring_fine} = $recurring_fine_rule->low
1434         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1435     $policy->{recurring_fine} = $recurring_fine_rule->normal
1436         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1437     $policy->{recurring_fine} = $recurring_fine_rule->high
1438         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1439
1440     return $policy;
1441 }
1442
1443 sub get_max_fine_amount {
1444     my $self = shift;
1445     my $max_fine_rule = shift;
1446     my $max_amount = $max_fine_rule->amount;
1447
1448     # if is_percent is true then the max->amount is
1449     # use as a percentage of the copy price
1450     if ($U->is_true($max_fine_rule->is_percent)) {
1451         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1452         $max_amount = $price * $max_fine_rule->amount / 100;
1453     } elsif (
1454         $U->ou_ancestor_setting_value(
1455             $self->circ_lib,
1456             'circ.max_fine.cap_at_price',
1457             $self->editor
1458         )
1459     ) {
1460         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1461         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1462     }
1463
1464     return $max_amount;
1465 }
1466
1467
1468
1469 sub run_copy_permit_scripts {
1470     my $self = shift;
1471     my $copy = $self->copy || return;
1472
1473     my @allevents;
1474
1475     my $results = $self->run_indb_circ_test;
1476     push @allevents, $self->matrix_test_result_events
1477         unless $self->circ_test_success;
1478
1479     # See if this copy has an alert message
1480     my $ae = $self->check_copy_alert();
1481     push( @allevents, $ae ) if $ae;
1482
1483     # uniquify the events
1484     my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1485     @allevents = values %hash;
1486
1487     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1488
1489     $self->push_events(@allevents);
1490 }
1491
1492
1493 sub check_copy_alert {
1494     my $self = shift;
1495
1496     if ($self->new_copy_alerts) {
1497         my @alerts;
1498         push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts 
1499             if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1500
1501         push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts 
1502             if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1503
1504         if (@alerts) {
1505             $self->bail_out(1) if (!$self->override);
1506             return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1507         }
1508     }
1509
1510     return undef if $self->is_renewal;
1511     return OpenILS::Event->new(
1512         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1513         if $self->copy and $self->copy->alert_message;
1514     return undef;
1515 }
1516
1517
1518
1519 # --------------------------------------------------------------------------
1520 # If the call is overriding and has permissions to override every collected
1521 # event, the are cleared.  Any event that the caller does not have
1522 # permission to override, will be left in the event list and bail_out will
1523 # be set
1524 # XXX We need code in here to cancel any holds/transits on copies 
1525 # that are being force-checked out
1526 # --------------------------------------------------------------------------
1527 sub override_events {
1528     my $self = shift;
1529     my @events = @{$self->events};
1530     return unless @events;
1531     my $oargs = $self->override_args;
1532
1533     if(!$self->override) {
1534         return $self->bail_out(1) 
1535             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1536     }   
1537
1538     $self->events([]);
1539     
1540     for my $e (@events) {
1541         my $tc = $e->{textcode};
1542         next if $tc eq 'SUCCESS';
1543         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1544             my $ov = "$tc.override";
1545             $logger->info("circulator: attempting to override event: $ov");
1546
1547             return $self->bail_on_events($self->editor->event)
1548                 unless( $self->editor->allowed($ov) );
1549         } else {
1550             return $self->bail_out(1);
1551         }
1552    }
1553 }
1554     
1555
1556 # --------------------------------------------------------------------------
1557 # If there is an open claimsreturn circ on the requested copy, close the 
1558 # circ if overriding, otherwise bail out
1559 # --------------------------------------------------------------------------
1560 sub handle_claims_returned {
1561     my $self = shift;
1562     my $copy = $self->copy;
1563
1564     my $CR = $self->editor->search_action_circulation(
1565         {   
1566             target_copy     => $copy->id,
1567             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1568             checkin_time    => undef,
1569         }
1570     );
1571
1572     return unless ($CR = $CR->[0]); 
1573
1574     my $evt;
1575
1576     # - If the caller has set the override flag, we will check the item in
1577     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1578
1579         $CR->checkin_time('now');   
1580         $CR->checkin_scan_time('now');   
1581         $CR->checkin_lib($self->circ_lib);
1582         $CR->checkin_workstation($self->editor->requestor->wsid);
1583         $CR->checkin_staff($self->editor->requestor->id);
1584
1585         $evt = $self->editor->event 
1586             unless $self->editor->update_action_circulation($CR);
1587
1588     } else {
1589         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1590     }
1591
1592     $self->bail_on_events($evt) if $evt;
1593     return;
1594 }
1595
1596
1597 # --------------------------------------------------------------------------
1598 # This performs the checkout
1599 # --------------------------------------------------------------------------
1600 sub do_checkout {
1601     my $self = shift;
1602
1603     $self->log_me("do_checkout()");
1604
1605     # make sure perms are good if this isn't a renewal
1606     unless( $self->is_renewal ) {
1607         return $self->bail_on_events($self->editor->event)
1608             unless( $self->editor->allowed('COPY_CHECKOUT') );
1609     }
1610
1611     # verify the permit key
1612     unless( $self->check_permit_key ) {
1613         if( $self->permit_override ) {
1614             return $self->bail_on_events($self->editor->event)
1615                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1616         } else {
1617             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1618         }   
1619     }
1620
1621     # if this is a non-cataloged circ, build the circ and finish
1622     if( $self->is_noncat ) {
1623         $self->checkout_noncat;
1624         $self->push_events(
1625             OpenILS::Event->new('SUCCESS', 
1626             payload => { noncat_circ => $self->circ }));
1627         return;
1628     }
1629
1630     if( $self->is_precat ) {
1631         $self->make_precat_copy;
1632         return if $self->bail_out;
1633
1634     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1635         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1636     }
1637
1638     $self->do_copy_checks;
1639     return if $self->bail_out;
1640
1641     $self->run_checkout_scripts();
1642     return if $self->bail_out;
1643
1644     $self->build_checkout_circ_object();
1645     return if $self->bail_out;
1646
1647     my $modify_to_start = $self->booking_adjusted_due_date();
1648     return if $self->bail_out;
1649
1650     $self->apply_modified_due_date($modify_to_start);
1651     return if $self->bail_out;
1652
1653     return $self->bail_on_events($self->editor->event)
1654         unless $self->editor->create_action_circulation($self->circ);
1655
1656     # refresh the circ to force local time zone for now
1657     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1658
1659     if($self->limit_groups) {
1660         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1661     }
1662
1663     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1664     $self->update_copy;
1665     return if $self->bail_out;
1666
1667     $self->apply_deposit_fee();
1668     return if $self->bail_out;
1669
1670     $self->handle_checkout_holds();
1671     return if $self->bail_out;
1672
1673     # ------------------------------------------------------------------------------
1674     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1675     # since the penalties are not updated during the permit phase
1676     # ------------------------------------------------------------------------------
1677     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1678
1679     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1680     
1681     my $pcirc;
1682     if($self->is_renewal) {
1683         # flesh the billing summary for the checked-in circ
1684         $pcirc = $self->editor->retrieve_action_circulation([
1685             $self->parent_circ,
1686             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1687         ]);
1688     }
1689
1690     $self->push_events(
1691         OpenILS::Event->new('SUCCESS',
1692             payload  => {
1693                 copy             => $U->unflesh_copy($self->copy),
1694                 volume           => $self->volume,
1695                 circ             => $self->circ,
1696                 record           => $record,
1697                 holds_fulfilled  => $self->fulfilled_holds,
1698                 deposit_billing  => $self->deposit_billing,
1699                 rental_billing   => $self->rental_billing,
1700                 parent_circ      => $pcirc,
1701                 patron           => ($self->return_patron) ? $self->patron : undef,
1702                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1703             }
1704         )
1705     );
1706 }
1707
1708 sub apply_deposit_fee {
1709     my $self = shift;
1710     my $copy = $self->copy;
1711     return unless 
1712         ($self->is_deposit and not $self->is_deposit_exempt) or 
1713         ($self->is_rental and not $self->is_rental_exempt);
1714
1715     return if $self->is_deposit and $self->skip_deposit_fee;
1716     return if $self->is_rental and $self->skip_rental_fee;
1717
1718     my $bill = Fieldmapper::money::billing->new;
1719     my $amount = $copy->deposit_amount;
1720     my $billing_type;
1721     my $btype;
1722
1723     if($self->is_deposit) {
1724         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1725         $btype = 5;
1726         $self->deposit_billing($bill);
1727     } else {
1728         $billing_type = OILS_BILLING_TYPE_RENTAL;
1729         $btype = 6;
1730         $self->rental_billing($bill);
1731     }
1732
1733     $bill->xact($self->circ->id);
1734     $bill->amount($amount);
1735     $bill->note(OILS_BILLING_NOTE_SYSTEM);
1736     $bill->billing_type($billing_type);
1737     $bill->btype($btype);
1738     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1739
1740     $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1741 }
1742
1743 sub update_copy {
1744     my $self = shift;
1745     my $copy = $self->copy;
1746
1747     my $stat = $copy->status if ref $copy->status;
1748     my $loc = $copy->location if ref $copy->location;
1749     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1750
1751     $copy->status($stat->id) if $stat;
1752     $copy->location($loc->id) if $loc;
1753     $copy->circ_lib($circ_lib->id) if $circ_lib;
1754     $copy->editor($self->editor->requestor->id);
1755     $copy->edit_date('now');
1756     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1757
1758     return $self->bail_on_events($self->editor->event)
1759         unless $self->editor->update_asset_copy($self->copy);
1760
1761     $copy->status($U->copy_status($copy->status));
1762     $copy->location($loc) if $loc;
1763     $copy->circ_lib($circ_lib) if $circ_lib;
1764 }
1765
1766 sub update_reservation {
1767     my $self = shift;
1768     my $reservation = $self->reservation;
1769
1770     my $usr = $reservation->usr;
1771     my $target_rt = $reservation->target_resource_type;
1772     my $target_r = $reservation->target_resource;
1773     my $current_r = $reservation->current_resource;
1774
1775     $reservation->usr($usr->id) if ref $usr;
1776     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1777     $reservation->target_resource($target_r->id) if ref $target_r;
1778     $reservation->current_resource($current_r->id) if ref $current_r;
1779
1780     return $self->bail_on_events($self->editor->event)
1781         unless $self->editor->update_booking_reservation($self->reservation);
1782
1783     my $evt;
1784     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1785     $self->reservation($reservation);
1786 }
1787
1788
1789 sub bail_on_events {
1790     my( $self, @evts ) = @_;
1791     $self->push_events(@evts);
1792     $self->bail_out(1);
1793 }
1794
1795 # ------------------------------------------------------------------------------
1796 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1797 # affects copies that will fulfill holds and CIRC affects all other copies.
1798 # If blocks exists, bail, push Events onto the event pile, and return true.
1799 # ------------------------------------------------------------------------------
1800 sub check_hold_fulfill_blocks {
1801     my $self = shift;
1802
1803     # With the addition of ignore_proximity in csp, we need to fetch
1804     # the proximity of both the circ_lib and the copy's circ_lib to
1805     # the patron's home_ou.
1806     my ($ou_prox, $copy_prox);
1807     my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1808     $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1809     $ou_prox = -1 unless (defined($ou_prox));
1810     my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1811     if ($copy_ou == $self->circ_lib) {
1812         # Save us the time of an extra query.
1813         $copy_prox = $ou_prox;
1814     } else {
1815         $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1816         $copy_prox = -1 unless (defined($copy_prox));
1817     }
1818
1819     # See if the user has any penalties applied that prevent hold fulfillment
1820     my $pens = $self->editor->json_query({
1821         select => {csp => ['name', 'label']},
1822         from => {ausp => {csp => {}}},
1823         where => {
1824             '+ausp' => {
1825                 usr => $self->patron->id,
1826                 org_unit => $U->get_org_full_path($self->circ_lib),
1827                 '-or' => [
1828                     {stop_date => undef},
1829                     {stop_date => {'>' => 'now'}}
1830                 ]
1831             },
1832             '+csp' => {
1833                 block_list => {'like' => '%FULFILL%'},
1834                 '-or' => [
1835                     {ignore_proximity => undef},
1836                     {ignore_proximity => {'<' => $ou_prox}},
1837                     {ignore_proximity => {'<' => $copy_prox}}
1838                 ]
1839             }
1840         }
1841     });
1842
1843     return 0 unless @$pens;
1844
1845     for my $pen (@$pens) {
1846         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1847         my $event = OpenILS::Event->new($pen->{name});
1848         $event->{desc} = $pen->{label};
1849         $self->push_events($event);
1850     }
1851
1852     $self->override_events;
1853     return $self->bail_out;
1854 }
1855
1856
1857 # ------------------------------------------------------------------------------
1858 # When an item is checked out, see if we can fulfill a hold for this patron
1859 # ------------------------------------------------------------------------------
1860 sub handle_checkout_holds {
1861    my $self    = shift;
1862    my $copy    = $self->copy;
1863    my $patron  = $self->patron;
1864
1865    my $e = $self->editor;
1866    $self->fulfilled_holds([]);
1867
1868    # non-cats can't fulfill a hold
1869    return if $self->is_noncat;
1870
1871     my $hold = $e->search_action_hold_request({   
1872         current_copy        => $copy->id , 
1873         cancel_time         => undef, 
1874         fulfillment_time    => undef
1875     })->[0];
1876
1877     if($hold and $hold->usr != $patron->id) {
1878         # reset the hold since the copy is now checked out
1879     
1880         $logger->info("circulator: un-targeting hold ".$hold->id.
1881             " because copy ".$copy->id." is getting checked out");
1882
1883         $hold->clear_prev_check_time; 
1884         $hold->clear_current_copy;
1885         $hold->clear_capture_time;
1886         $hold->clear_shelf_time;
1887         $hold->clear_shelf_expire_time;
1888         $hold->clear_current_shelf_lib;
1889
1890         return $self->bail_on_event($e->event)
1891             unless $e->update_action_hold_request($hold);
1892
1893         $hold = undef;
1894     }
1895
1896     unless($hold) {
1897         $hold = $self->find_related_user_hold($copy, $patron) or return;
1898         $logger->info("circulator: found related hold to fulfill in checkout");
1899     }
1900
1901     return if $self->check_hold_fulfill_blocks;
1902
1903     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1904
1905     # if the hold was never officially captured, capture it.
1906     $hold->current_copy($copy->id);
1907     $hold->capture_time('now') unless $hold->capture_time;
1908     $hold->fulfillment_time('now');
1909     $hold->fulfillment_staff($e->requestor->id);
1910     $hold->fulfillment_lib($self->circ_lib);
1911
1912     return $self->bail_on_events($e->event)
1913         unless $e->update_action_hold_request($hold);
1914
1915     return $self->fulfilled_holds([$hold->id]);
1916 }
1917
1918
1919 # ------------------------------------------------------------------------------
1920 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1921 # the patron directly targets the checked out item, see if there is another hold 
1922 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1923 # oldest hold and only fulfill 1 of them.
1924
1925 # For "another hold":
1926 #
1927 # First, check for one that the copy matches via hold_copy_map, ensuring that
1928 # *any* hold type that this copy could fill may end up filled.
1929 #
1930 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1931 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1932 # that are non-requestable to count as capturing those hold types.
1933 # ------------------------------------------------------------------------------
1934 sub find_related_user_hold {
1935     my($self, $copy, $patron) = @_;
1936     my $e = $self->editor;
1937
1938     # holds on precat copies are always copy-level, so this call will
1939     # always return undef.  Exit early.
1940     return undef if $self->is_precat;
1941
1942     return undef unless $U->ou_ancestor_setting_value(        
1943         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1944
1945     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1946     my $args = {
1947         select => {ahr => ['id']}, 
1948         from => {
1949             ahr => {
1950                 ahcm => {
1951                     field => 'hold',
1952                     fkey => 'id'
1953                 },
1954                 acp => {
1955                     field => 'id', 
1956                     fkey => 'current_copy',
1957                     type => 'left' # there may be no current_copy
1958                 }
1959             }
1960         }, 
1961         where => {
1962             '+ahr' => {
1963                 usr => $patron->id,
1964                 fulfillment_time => undef,
1965                 cancel_time => undef,
1966                '-or' => [
1967                     {expire_time => undef},
1968                     {expire_time => {'>' => 'now'}}
1969                 ]
1970             },
1971             '+ahcm' => {
1972                 target_copy => $self->copy->id
1973             },
1974             '+acp' => {
1975                 '-or' => [
1976                     {id => undef}, # left-join copy may be nonexistent
1977                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1978                 ]
1979             }
1980         },
1981         order_by => {ahr => {request_time => {direction => 'asc'}}},
1982         limit => 1
1983     };
1984
1985     my $hold_info = $e->json_query($args)->[0];
1986     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1987     return undef if $U->ou_ancestor_setting_value(        
1988         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1989
1990     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1991     $args = {
1992         select => {ahr => ['id']}, 
1993         from => {
1994             ahr => {
1995                 acp => {
1996                     field => 'id', 
1997                     fkey => 'current_copy',
1998                     type => 'left' # there may be no current_copy
1999                 }
2000             }
2001         }, 
2002         where => {
2003             '+ahr' => {
2004                 usr => $patron->id,
2005                 fulfillment_time => undef,
2006                 cancel_time => undef,
2007                '-or' => [
2008                     {expire_time => undef},
2009                     {expire_time => {'>' => 'now'}}
2010                 ]
2011             },
2012             '-or' => [
2013                 {
2014                     '+ahr' => { 
2015                         hold_type => 'V',
2016                         target => $self->volume->id
2017                     }
2018                 },
2019                 { 
2020                     '+ahr' => { 
2021                         hold_type => 'T',
2022                         target => $self->title->id
2023                     }
2024                 },
2025             ],
2026             '+acp' => {
2027                 '-or' => [
2028                     {id => undef}, # left-join copy may be nonexistent
2029                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2030                 ]
2031             }
2032         },
2033         order_by => {ahr => {request_time => {direction => 'asc'}}},
2034         limit => 1
2035     };
2036
2037     $hold_info = $e->json_query($args)->[0];
2038     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2039     return undef;
2040 }
2041
2042
2043 sub run_checkout_scripts {
2044     my $self = shift;
2045     my $nobail = shift;
2046
2047     my $evt;
2048
2049     my $duration;
2050     my $recurring;
2051     my $max_fine;
2052     my $hard_due_date;
2053     my $duration_name;
2054     my $recurring_name;
2055     my $max_fine_name;
2056     my $hard_due_date_name;
2057
2058     $self->run_indb_circ_test();
2059     $duration = $self->circ_matrix_matchpoint->duration_rule;
2060     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2061     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2062     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2063
2064     $duration_name = $duration->name if $duration;
2065     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2066
2067         unless($duration) {
2068             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2069             return $self->bail_on_events($evt) if ($evt && !$nobail);
2070         
2071             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2072             return $self->bail_on_events($evt) if ($evt && !$nobail);
2073         
2074             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2075             return $self->bail_on_events($evt) if ($evt && !$nobail);
2076
2077             if($hard_due_date_name) {
2078                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2079                 return $self->bail_on_events($evt) if ($evt && !$nobail);
2080             }
2081         }
2082
2083     } else {
2084
2085         # The item circulates with an unlimited duration
2086         $duration   = undef;
2087         $recurring  = undef;
2088         $max_fine   = undef;
2089         $hard_due_date = undef;
2090     }
2091
2092    $self->duration_rule($duration);
2093    $self->recurring_fines_rule($recurring);
2094    $self->max_fine_rule($max_fine);
2095    $self->hard_due_date($hard_due_date);
2096 }
2097
2098
2099 sub build_checkout_circ_object {
2100     my $self = shift;
2101
2102    my $circ       = Fieldmapper::action::circulation->new;
2103    my $duration   = $self->duration_rule;
2104    my $max        = $self->max_fine_rule;
2105    my $recurring  = $self->recurring_fines_rule;
2106    my $hard_due_date    = $self->hard_due_date;
2107    my $copy       = $self->copy;
2108    my $patron     = $self->patron;
2109    my $duration_date_ceiling;
2110    my $duration_date_ceiling_force;
2111
2112     if( $duration ) {
2113
2114         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2115         $duration_date_ceiling = $policy->{duration_date_ceiling};
2116         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2117
2118         my $dname = $duration->name;
2119         my $mname = $max->name;
2120         my $rname = $recurring->name;
2121         my $hdname = ''; 
2122         if($hard_due_date) {
2123             $hdname = $hard_due_date->name;
2124         }
2125
2126         $logger->debug("circulator: building circulation ".
2127             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2128     
2129         $circ->duration($policy->{duration});
2130         $circ->recurring_fine($policy->{recurring_fine});
2131         $circ->duration_rule($duration->name);
2132         $circ->recurring_fine_rule($recurring->name);
2133         $circ->max_fine_rule($max->name);
2134         $circ->max_fine($policy->{max_fine});
2135         $circ->fine_interval($recurring->recurrence_interval);
2136         $circ->renewal_remaining($duration->max_renewals);
2137         $circ->auto_renewal_remaining($duration->max_auto_renewals);
2138         $circ->grace_period($policy->{grace_period});
2139
2140     } else {
2141
2142         $logger->info("circulator: copy found with an unlimited circ duration");
2143         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2144         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2145         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2146         $circ->renewal_remaining(0);
2147         $circ->grace_period(0);
2148     }
2149
2150    $circ->target_copy( $copy->id );
2151    $circ->usr( $patron->id );
2152    $circ->circ_lib( $self->circ_lib );
2153    $circ->workstation($self->editor->requestor->wsid) 
2154     if defined $self->editor->requestor->wsid;
2155
2156     # renewals maintain a link to the parent circulation
2157     $circ->parent_circ($self->parent_circ);
2158
2159    if( $self->is_renewal ) {
2160       $circ->opac_renewal('t') if $self->opac_renewal;
2161       $circ->phone_renewal('t') if $self->phone_renewal;
2162       $circ->desk_renewal('t') if $self->desk_renewal;
2163       $circ->auto_renewal('t') if $self->auto_renewal;
2164       $circ->renewal_remaining($self->renewal_remaining);
2165       $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2166       $circ->circ_staff($self->editor->requestor->id);
2167    }
2168
2169     # if the user provided an overiding checkout time,
2170     # (e.g. the checkout really happened several hours ago), then
2171     # we apply that here.  Does this need a perm??
2172     $circ->xact_start(clean_ISO8601($self->checkout_time))
2173         if $self->checkout_time;
2174
2175     # if a patron is renewing, 'requestor' will be the patron
2176     $circ->circ_staff($self->editor->requestor->id);
2177     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2178
2179     $self->circ($circ);
2180 }
2181
2182 sub do_reservation_pickup {
2183     my $self = shift;
2184
2185     $self->log_me("do_reservation_pickup()");
2186
2187     $self->reservation->pickup_time('now');
2188
2189     if (
2190         $self->reservation->current_resource &&
2191         $U->is_true($self->reservation->target_resource_type->catalog_item)
2192     ) {
2193         # We used to try to set $self->copy and $self->patron here,
2194         # but that should already be done.
2195
2196         $self->run_checkout_scripts(1);
2197
2198         my $duration   = $self->duration_rule;
2199         my $max        = $self->max_fine_rule;
2200         my $recurring  = $self->recurring_fines_rule;
2201
2202         if ($duration && $max && $recurring) {
2203             my $policy = $self->get_circ_policy($duration, $recurring, $max);
2204
2205             my $dname = $duration->name;
2206             my $mname = $max->name;
2207             my $rname = $recurring->name;
2208
2209             $logger->debug("circulator: updating reservation ".
2210                 "with duration=$dname, maxfine=$mname, recurring=$rname");
2211
2212             $self->reservation->fine_amount($policy->{recurring_fine});
2213             $self->reservation->max_fine($policy->{max_fine});
2214             $self->reservation->fine_interval($recurring->recurrence_interval);
2215         }
2216
2217         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2218         $self->update_copy();
2219
2220     } else {
2221         $self->reservation->fine_amount(
2222             $self->reservation->target_resource_type->fine_amount
2223         );
2224         $self->reservation->max_fine(
2225             $self->reservation->target_resource_type->max_fine
2226         );
2227         $self->reservation->fine_interval(
2228             $self->reservation->target_resource_type->fine_interval
2229         );
2230     }
2231
2232     $self->update_reservation();
2233 }
2234
2235 sub do_reservation_return {
2236     my $self = shift;
2237     my $request = shift;
2238
2239     $self->log_me("do_reservation_return()");
2240
2241     if (not ref $self->reservation) {
2242         my ($reservation, $evt) =
2243             $U->fetch_booking_reservation($self->reservation);
2244         return $self->bail_on_events($evt) if $evt;
2245         $self->reservation($reservation);
2246     }
2247
2248     $self->handle_fines(1);
2249     $self->reservation->return_time('now');
2250     $self->update_reservation();
2251     $self->reshelve_copy if $self->copy;
2252
2253     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2254         $self->copy( $self->reservation->current_resource->catalog_item );
2255     }
2256 }
2257
2258 sub booking_adjusted_due_date {
2259     my $self = shift;
2260     my $circ = $self->circ;
2261     my $copy = $self->copy;
2262
2263     return undef unless $self->use_booking;
2264
2265     my $changed;
2266
2267     if( $self->due_date ) {
2268
2269         return $self->bail_on_events($self->editor->event)
2270             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2271
2272        $circ->due_date(clean_ISO8601($self->due_date));
2273
2274     } else {
2275
2276         return unless $copy and $circ->due_date;
2277     }
2278
2279     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2280     if (@$booking_items) {
2281         my $booking_item = $booking_items->[0];
2282         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2283
2284         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2285         my $shorten_circ_setting = $resource_type->elbow_room ||
2286             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2287             '0 seconds';
2288
2289         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2290         my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2291               resource     => $booking_item->id
2292             , search_start => 'now'
2293             , search_end   => $circ->due_date
2294             , fields       => { cancel_time => undef, return_time => undef }
2295         })->gather(1);
2296         $booking_ses->disconnect;
2297
2298         throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2299         return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2300         
2301         my $dt_parser = DateTime::Format::ISO8601->new;
2302         my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2303
2304         for my $bid (@$bookings) {
2305
2306             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2307
2308             my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2309             my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2310
2311             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2312                 if ($booking_start < DateTime->now);
2313
2314
2315             if ($U->is_true($stop_circ_setting)) {
2316                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2317             } else {
2318                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2319                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2320             }
2321             
2322             # We set the circ duration here only to affect the logic that will
2323             # later (in a DB trigger) mangle the time part of the due date to
2324             # 11:59pm. Having any circ duration that is not a whole number of
2325             # days is enough to prevent the "correction."
2326             my $new_circ_duration = $due_date->epoch - time;
2327             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2328             $circ->duration("$new_circ_duration seconds");
2329
2330             $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2331             $changed = 1;
2332         }
2333
2334         return $self->bail_on_events($self->editor->event)
2335             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2336     }
2337
2338     return $changed;
2339 }
2340
2341 sub apply_modified_due_date {
2342     my $self = shift;
2343     my $shift_earlier = shift;
2344     my $circ = $self->circ;
2345     my $copy = $self->copy;
2346
2347    if( $self->due_date ) {
2348
2349         return $self->bail_on_events($self->editor->event)
2350             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2351
2352       $circ->due_date(clean_ISO8601($self->due_date));
2353
2354    } else {
2355
2356       # if the due_date lands on a day when the location is closed
2357       return unless $copy and $circ->due_date;
2358
2359         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2360
2361         # due-date overlap should be determined by the location the item
2362         # is checked out from, not the owning or circ lib of the item
2363         my $org = $self->circ_lib;
2364
2365       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2366             " with an item due date of ".$circ->due_date );
2367
2368       my $dateinfo = $U->storagereq(
2369          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2370             $org, $circ->due_date );
2371
2372       if($dateinfo) {
2373          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2374             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2375
2376             # XXX make the behavior more dynamic
2377             # for now, we just push the due date to after the close date
2378             if ($shift_earlier) {
2379                 $circ->due_date($dateinfo->{start});
2380             } else {
2381                 $circ->due_date($dateinfo->{end});
2382             }
2383       }
2384    }
2385 }
2386
2387
2388
2389 sub create_due_date {
2390     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2391
2392     # Look up circulating library's TZ, or else use client TZ, falling
2393     # back to server TZ
2394     my $tz = $U->ou_ancestor_setting_value(
2395         $self->circ_lib,
2396         'lib.timezone',
2397         $self->editor
2398     ) || 'local';
2399
2400     my $due_date = $start_time ?
2401         DateTime::Format::ISO8601
2402             ->new
2403             ->parse_datetime(clean_ISO8601($start_time))
2404             ->set_time_zone($tz) :
2405         DateTime->now(time_zone => $tz);
2406
2407     # add the circ duration
2408     $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2409
2410     if($date_ceiling) {
2411         my $cdate = DateTime::Format::ISO8601
2412             ->new
2413             ->parse_datetime(clean_ISO8601($date_ceiling))
2414             ->set_time_zone($tz);
2415
2416         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2417             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2418             $due_date = $cdate;
2419         }
2420     }
2421
2422     # return ISO8601 time with timezone
2423     return $due_date->strftime('%FT%T%z');
2424 }
2425
2426
2427
2428 sub make_precat_copy {
2429     my $self = shift;
2430     my $copy = $self->copy;
2431     return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2432        unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2433
2434    if($copy) {
2435         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2436
2437         $copy->editor($self->editor->requestor->id);
2438         $copy->edit_date('now');
2439         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2440         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2441         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2442         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2443         $self->update_copy();
2444         return;
2445    }
2446
2447     $logger->info("circulator: Creating a new precataloged ".
2448         "copy in checkout with barcode " . $self->copy_barcode);
2449
2450     $copy = Fieldmapper::asset::copy->new;
2451     $copy->circ_lib($self->circ_lib);
2452     $copy->creator($self->editor->requestor->id);
2453     $copy->editor($self->editor->requestor->id);
2454     $copy->barcode($self->copy_barcode);
2455     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2456     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2457     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2458
2459     $copy->dummy_title($self->dummy_title || "");
2460     $copy->dummy_author($self->dummy_author || "");
2461     $copy->dummy_isbn($self->dummy_isbn || "");
2462     $copy->circ_modifier($self->circ_modifier);
2463
2464
2465     # See if we need to override the circ_lib for the copy with a configured circ_lib
2466     # Setting is shortname of the org unit
2467     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2468         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2469
2470     if($precat_circ_lib) {
2471         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2472
2473         if(!$org) {
2474             $self->bail_on_events($self->editor->event);
2475             return;
2476         }
2477
2478         $copy->circ_lib($org->id);
2479     }
2480
2481
2482     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2483         $self->bail_out(1);
2484         $self->push_events($self->editor->event);
2485         return;
2486     }   
2487 }
2488
2489
2490 sub checkout_noncat {
2491     my $self = shift;
2492
2493     my $circ;
2494     my $evt;
2495
2496    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2497    my $count    = $self->noncat_count || 1;
2498    my $cotime   = clean_ISO8601($self->checkout_time) || "";
2499
2500    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2501
2502    for(1..$count) {
2503
2504       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2505          $self->editor->requestor->id, 
2506             $self->patron->id, 
2507             $lib, 
2508             $self->noncat_type, 
2509             $cotime,
2510             $self->editor );
2511
2512         if( $evt ) {
2513             $self->push_events($evt);
2514             $self->bail_out(1);
2515             return; 
2516         }
2517         $self->circ($circ);
2518    }
2519 }
2520
2521 # If a copy goes into transit and is then checked in before the transit checkin 
2522 # interval has expired, push an event onto the overridable events list.
2523 sub check_transit_checkin_interval {
2524     my $self = shift;
2525
2526     # only concerned with in-transit items
2527     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2528
2529     # no interval, no problem
2530     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2531     return unless $interval;
2532
2533     # capture the transit so we don't have to fetch it again later during checkin
2534     $self->transit(
2535         $self->editor->search_action_transit_copy(
2536             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2537         )->[0]
2538     ); 
2539
2540     # transit from X to X for whatever reason has no min interval
2541     return if $self->transit->source == $self->transit->dest;
2542
2543     my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2544     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2545     my $horizon = $t_start->add(seconds => $seconds);
2546
2547     # See if we are still within the transit checkin forbidden range
2548     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2549         if $horizon > DateTime->now;
2550 }
2551
2552 # Retarget local holds at checkin
2553 sub checkin_retarget {
2554     my $self = shift;
2555     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2556     return unless $self->is_checkin; # Renewals need not be checked
2557     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2558     return if $self->is_precat; # No holds for precats
2559     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2560     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2561     my $status = $U->copy_status($self->copy->status);
2562     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2563     # Specifically target items that are likely new (by status ID)
2564     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2565     my $location = $self->copy->location;
2566     if(!ref($location)) {
2567         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2568         $self->copy->location($location);
2569     }
2570     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2571
2572     # Fetch holds for the bib
2573     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2574                     $self->editor->authtoken,
2575                     $self->title->id,
2576                     {
2577                         capture_time => undef, # No touching captured holds
2578                         frozen => 'f', # Don't bother with frozen holds
2579                         pickup_lib => $self->circ_lib # Only holds actually here
2580                     }); 
2581
2582     # Error? Skip the step.
2583     return if exists $result->{"ilsevent"};
2584
2585     # Assemble holds
2586     my $holds = [];
2587     foreach my $holdlist (keys %{$result}) {
2588         push @$holds, @{$result->{$holdlist}};
2589     }
2590
2591     return if scalar(@$holds) == 0; # No holds, no retargeting
2592
2593     # Check for parts on this copy
2594     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2595     my %parts_hash = ();
2596     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2597
2598     # Loop over holds in request-ish order
2599     # Stage 1: Get them into request-ish order
2600     # Also grab type and target for skipping low hanging ones
2601     $result = $self->editor->json_query({
2602         "select" => { "ahr" => ["id", "hold_type", "target"] },
2603         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2604         "where" => { "id" => $holds },
2605         "order_by" => [
2606             { "class" => "pgt", "field" => "hold_priority"},
2607             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2608             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2609             { "class" => "ahr", "field" => "request_time"}
2610         ]
2611     });
2612
2613     # Stage 2: Loop!
2614     if (ref $result eq "ARRAY" and scalar @$result) {
2615         foreach (@{$result}) {
2616             # Copy level, but not this copy?
2617             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2618                 and $_->{target} != $self->copy->id);
2619             # Volume level, but not this volume?
2620             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2621             if(@$parts) { # We have parts?
2622                 # Skip title holds
2623                 next if ($_->{hold_type} eq 'T');
2624                 # Skip part holds for parts not on this copy
2625                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2626             } else {
2627                 # No parts, no part holds
2628                 next if ($_->{hold_type} eq 'P');
2629             }
2630             # So much for easy stuff, attempt a retarget!
2631             my $tresult = $U->simplereq(
2632                 'open-ils.hold-targeter',
2633                 'open-ils.hold-targeter.target', 
2634                 {hold => $_->{id}, find_copy => $self->copy->id}
2635             );
2636             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2637                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2638             }
2639         }
2640     }
2641 }
2642
2643 sub do_checkin {
2644     my $self = shift;
2645     $self->log_me("do_checkin()");
2646
2647     return $self->bail_on_events(
2648         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2649         unless $self->copy;
2650
2651     $self->check_transit_checkin_interval;
2652     $self->checkin_retarget;
2653
2654     # the renew code and mk_env should have already found our circulation object
2655     unless( $self->circ ) {
2656
2657         my $circs = $self->editor->search_action_circulation(
2658             { target_copy => $self->copy->id, checkin_time => undef });
2659
2660         $self->circ($$circs[0]);
2661
2662         # for now, just warn if there are multiple open circs on a copy
2663         $logger->warn("circulator: we have ".scalar(@$circs).
2664             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2665     }
2666
2667     my $stat = $U->copy_status($self->copy->status)->id;
2668
2669     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2670     # differently if they are already paid for.  We need to check for this
2671     # early since overdue generation is potentially affected.
2672     my $dont_change_lost_zero = 0;
2673     if ($stat == OILS_COPY_STATUS_LOST
2674         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2675         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2676
2677         # LOST fine settings are controlled by the copy's circ lib, not the the
2678         # circulation's
2679         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2680                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2681         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2682             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2683             $self->editor) || 0;
2684
2685         # Don't assume there's always a circ based on copy status
2686         if ($dont_change_lost_zero && $self->circ) {
2687             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2688             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2689         }
2690
2691         $self->dont_change_lost_zero($dont_change_lost_zero);
2692     }
2693
2694     my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2695
2696     if ($self->do_inventory_update) {
2697         $latest_inventory->inventory_date('now');
2698         $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2699         $latest_inventory->copy($self->copy->id());
2700     } else {
2701         my $alci = $self->editor->search_asset_latest_inventory(
2702             {copy => $self->copy->id}
2703         );
2704         $latest_inventory = $alci->[0]
2705     }
2706     $self->latest_inventory($latest_inventory);
2707
2708     if( $self->checkin_check_holds_shelf() ) {
2709         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2710         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2711         if($self->fake_hold_dest) {
2712             $self->hold->pickup_lib($self->circ_lib);
2713         }
2714         $self->checkin_flesh_events;
2715         return;
2716     }
2717
2718     unless( $self->is_renewal ) {
2719         return $self->bail_on_events($self->editor->event)
2720             unless $self->editor->allowed('COPY_CHECKIN');
2721     }
2722
2723     $self->push_events($self->check_copy_alert());
2724     $self->push_events($self->check_checkin_copy_status());
2725
2726     # if the circ is marked as 'claims returned', add the event to the list
2727     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2728         if ($self->circ and $self->circ->stop_fines 
2729                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2730
2731     $self->check_circ_deposit();
2732
2733     # handle the overridable events 
2734     $self->override_events unless $self->is_renewal;
2735     return if $self->bail_out;
2736     
2737     if( $self->copy and !$self->transit ) {
2738         $self->transit(
2739             $self->editor->search_action_transit_copy(
2740                 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2741             )->[0]
2742         ); 
2743     }
2744
2745     if( $self->circ ) {
2746         $self->checkin_handle_circ_start;
2747         return if $self->bail_out;
2748
2749         if (!$dont_change_lost_zero) {
2750             # if this circ is LOST and we are configured to generate overdue
2751             # fines for lost items on checkin (to fill the gap between mark
2752             # lost time and when the fines would have naturally stopped), then
2753             # stop_fines is no longer valid and should be cleared.
2754             #
2755             # stop_fines will be set again during the handle_fines() stage.
2756             # XXX should this setting come from the copy circ lib (like other
2757             # LOST settings), instead of the circulation circ lib?
2758             if ($stat == OILS_COPY_STATUS_LOST) {
2759                 $self->circ->clear_stop_fines if
2760                     $U->ou_ancestor_setting_value(
2761                         $self->circ_lib,
2762                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2763                         $self->editor
2764                     );
2765             }
2766
2767             # Set stop_fines when claimed never checked out
2768             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2769
2770             # handle fines for this circ, including overdue gen if needed
2771             $self->handle_fines;
2772         }
2773
2774         $self->checkin_handle_circ_finish;
2775         return if $self->bail_out;
2776         $self->checkin_changed(1);
2777
2778     } elsif( $self->transit ) {
2779         my $hold_transit = $self->process_received_transit;
2780         $self->checkin_changed(1);
2781
2782         if( $self->bail_out ) { 
2783             $self->checkin_flesh_events;
2784             return;
2785         }
2786         
2787         if( my $e = $self->check_checkin_copy_status() ) {
2788             # If the original copy status is special, alert the caller
2789             my $ev = $self->events;
2790             $self->events([$e]);
2791             $self->override_events;
2792             return if $self->bail_out;
2793             $self->events($ev);
2794         }
2795
2796         if( $hold_transit or 
2797                 $U->copy_status($self->copy->status)->id 
2798                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2799
2800             my $hold;
2801             if( $hold_transit ) {
2802                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2803             } else {
2804                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2805             }
2806
2807             $self->hold($hold);
2808
2809             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2810
2811                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2812                 $self->reshelve_copy(1);
2813                 $self->cancelled_hold_transit(1);
2814                 $self->notify_hold(0); # don't notify for cancelled holds
2815                 $self->fake_hold_dest(0);
2816                 return if $self->bail_out;
2817
2818             } elsif ($hold and $hold->hold_type eq 'R') {
2819
2820                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2821                 $self->notify_hold(0); # No need to notify
2822                 $self->fake_hold_dest(0);
2823                 $self->noop(1); # Don't try and capture for other holds/transits now
2824                 $self->update_copy();
2825                 $hold->fulfillment_time('now');
2826                 $self->bail_on_events($self->editor->event)
2827                     unless $self->editor->update_action_hold_request($hold);
2828
2829             } else {
2830
2831                 # hold transited to correct location
2832                 if($self->fake_hold_dest) {
2833                     $hold->pickup_lib($self->circ_lib);
2834                 }
2835                 $self->checkin_flesh_events;
2836                 return;
2837             }
2838         } 
2839
2840     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2841
2842         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2843             " that is in-transit, but there is no transit.. repairing");
2844         $self->reshelve_copy(1);
2845         return if $self->bail_out;
2846     }
2847
2848     if( $self->is_renewal ) {
2849         $self->finish_fines_and_voiding;
2850         return if $self->bail_out;
2851         $self->push_events(OpenILS::Event->new('SUCCESS'));
2852         return;
2853     }
2854
2855    # ------------------------------------------------------------------------------
2856    # Circulations and transits are now closed where necessary.  Now go on to see if
2857    # this copy can fulfill a hold or needs to be routed to a different location
2858    # ------------------------------------------------------------------------------
2859
2860     my $needed_for_something = 0; # formerly "needed_for_hold"
2861
2862     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2863
2864         if (!$self->remote_hold) {
2865             if ($self->use_booking) {
2866                 my $potential_hold = $self->hold_capture_is_possible;
2867                 my $potential_reservation = $self->reservation_capture_is_possible;
2868
2869                 if ($potential_hold and $potential_reservation) {
2870                     $logger->info("circulator: item could fulfill either hold or reservation");
2871                     $self->push_events(new OpenILS::Event(
2872                         "HOLD_RESERVATION_CONFLICT",
2873                         "hold" => $potential_hold,
2874                         "reservation" => $potential_reservation
2875                     ));
2876                     return if $self->bail_out;
2877                 } elsif ($potential_hold) {
2878                     $needed_for_something =
2879                         $self->attempt_checkin_hold_capture;
2880                 } elsif ($potential_reservation) {
2881                     $needed_for_something =
2882                         $self->attempt_checkin_reservation_capture;
2883                 }
2884             } else {
2885                 $needed_for_something = $self->attempt_checkin_hold_capture;
2886             }
2887         }
2888         return if $self->bail_out;
2889     
2890         unless($needed_for_something) {
2891             my $circ_lib = (ref $self->copy->circ_lib) ? 
2892                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2893     
2894             if( $self->remote_hold ) {
2895                 $circ_lib = $self->remote_hold->pickup_lib;
2896                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2897                     " is on a remote hold's shelf, sending to $circ_lib");
2898             }
2899     
2900             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2901
2902             my $suppress_transit = 0;
2903
2904             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2905                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2906                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2907                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2908                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2909                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2910                         $suppress_transit = 1;
2911                     }
2912                 }
2913             }
2914  
2915             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2916                 # copy is where it needs to be, either for hold or reshelving
2917     
2918                 $self->checkin_handle_precat();
2919                 return if $self->bail_out;
2920     
2921             } else {
2922                 # copy needs to transit "home", or stick here if it's a floating copy
2923                 my $can_float = 0;
2924                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2925                     my $res = $self->editor->json_query(
2926                         {   from => [
2927                                 'evergreen.can_float',
2928                                 $self->copy->floating->id,
2929                                 $self->copy->circ_lib,
2930                                 $self->circ_lib
2931                             ]
2932                         }
2933                     );
2934                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2935                 }
2936                 if ($can_float) { # Yep, floating, stick here
2937                     $self->checkin_changed(1);
2938                     $self->copy->circ_lib( $self->circ_lib );
2939                     $self->update_copy;
2940                 } else {
2941                     my $bc = $self->copy->barcode;
2942                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2943                     $self->checkin_build_copy_transit($circ_lib);
2944                     return if $self->bail_out;
2945                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2946                 }
2947             }
2948         }
2949     } else { # no-op checkin
2950         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2951             my $res = $self->editor->json_query(
2952                 {
2953                     from => [
2954                         'evergreen.can_float',
2955                         $self->copy->floating->id,
2956                         $self->copy->circ_lib,
2957                         $self->circ_lib
2958                     ]
2959                 }
2960             );
2961             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2962                 $self->checkin_changed(1);
2963                 $self->copy->circ_lib( $self->circ_lib );
2964                 $self->update_copy;
2965             }
2966         }
2967     }
2968
2969     if($self->claims_never_checked_out and 
2970             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2971
2972         # the item was not supposed to be checked out to the user and should now be marked as missing
2973         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2974         $self->copy->status($next_status);
2975         $self->update_copy;
2976
2977     } else {
2978         $self->reshelve_copy unless $needed_for_something;
2979     }
2980
2981     return if $self->bail_out;
2982
2983     unless($self->checkin_changed) {
2984
2985         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2986         my $stat = $U->copy_status($self->copy->status)->id;
2987
2988         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2989          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2990         $self->bail_out(1); # no need to commit anything
2991
2992     } else {
2993
2994         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2995             unless @{$self->events};
2996     }
2997
2998     $self->finish_fines_and_voiding;
2999
3000     OpenILS::Utils::Penalty->calculate_penalties(
3001         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3002
3003     $self->checkin_flesh_events;
3004     return;
3005 }
3006
3007 sub finish_fines_and_voiding {
3008     my $self = shift;
3009     return unless $self->circ;
3010
3011     return unless $self->backdate or $self->void_overdues;
3012
3013     # void overdues after fine generation to prevent concurrent DB access to overdue billings
3014     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3015
3016     my $evt = $CC->void_or_zero_overdues(
3017         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3018
3019     return $self->bail_on_events($evt) if $evt;
3020
3021     # Make sure the circ is open or closed as necessary.
3022     $evt = $U->check_open_xact($self->editor, $self->circ->id);
3023     return $self->bail_on_events($evt) if $evt;
3024
3025     return undef;
3026 }
3027
3028
3029 # if a deposit was payed for this item, push the event
3030 sub check_circ_deposit {
3031     my $self = shift;
3032     return unless $self->circ;
3033     my $deposit = $self->editor->search_money_billing(
3034         {   btype => 5, 
3035             xact => $self->circ->id, 
3036             voided => 'f'
3037         }, {idlist => 1})->[0];
3038
3039     $self->push_events(OpenILS::Event->new(
3040         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3041 }
3042
3043 sub reshelve_copy {
3044    my $self    = shift;
3045    my $force   = $self->force || shift;
3046    my $copy    = $self->copy;
3047
3048    my $stat = $U->copy_status($copy->status)->id;
3049
3050    my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3051
3052    if($force || (
3053       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3054       $stat != OILS_COPY_STATUS_CATALOGING and
3055       $stat != OILS_COPY_STATUS_IN_TRANSIT and
3056       $stat != $next_status  )) {
3057
3058         $copy->status( $next_status );
3059             $self->update_copy;
3060             $self->checkin_changed(1);
3061     }
3062 }
3063
3064
3065 # Returns true if the item is at the current location
3066 # because it was transited there for a hold and the 
3067 # hold has not been fulfilled
3068 sub checkin_check_holds_shelf {
3069     my $self = shift;
3070     return 0 unless $self->copy;
3071
3072     return 0 unless 
3073         $U->copy_status($self->copy->status)->id ==
3074             OILS_COPY_STATUS_ON_HOLDS_SHELF;
3075
3076     # Attempt to clear shelf expired holds for this copy
3077     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3078         if($self->clear_expired);
3079
3080     # find the hold that put us on the holds shelf
3081     my $holds = $self->editor->search_action_hold_request(
3082         { 
3083             current_copy => $self->copy->id,
3084             capture_time => { '!=' => undef },
3085             fulfillment_time => undef,
3086             cancel_time => undef,
3087         }
3088     );
3089
3090     unless(@$holds) {
3091         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3092         $self->reshelve_copy(1);
3093         return 0;
3094     }
3095
3096     my $hold = $$holds[0];
3097
3098     $logger->info("circulator: we found a captured, un-fulfilled hold [".
3099         $hold->id. "] for copy ".$self->copy->barcode);
3100
3101     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3102         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3103         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3104             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3105             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3106                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3107                 $self->fake_hold_dest(1);
3108                 return 1;
3109             }
3110         }
3111     }
3112
3113     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3114         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3115         return 1;
3116     }
3117
3118     $logger->info("circulator: hold is not for here..");
3119     $self->remote_hold($hold);
3120     return 0;
3121 }
3122
3123
3124 sub checkin_handle_precat {
3125     my $self    = shift;
3126    my $copy    = $self->copy;
3127
3128    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3129         $copy->status(OILS_COPY_STATUS_CATALOGING);
3130         $self->update_copy();
3131         $self->checkin_changed(1);
3132         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3133    }
3134 }
3135
3136
3137 sub checkin_build_copy_transit {
3138     my $self            = shift;
3139     my $dest            = shift;
3140     my $copy       = $self->copy;
3141     my $transit    = Fieldmapper::action::transit_copy->new;
3142
3143     # if we are transiting an item to the shelf shelf, it's a hold transit
3144     if (my $hold = $self->remote_hold) {
3145         $transit = Fieldmapper::action::hold_transit_copy->new;
3146         $transit->hold($hold->id);
3147
3148         # the item is going into transit, remove any shelf-iness
3149         if ($hold->current_shelf_lib or $hold->shelf_time) {
3150             $hold->clear_current_shelf_lib;
3151             $hold->clear_shelf_time;
3152             return $self->bail_on_events($self->editor->event)
3153                 unless $self->editor->update_action_hold_request($hold);
3154         }
3155     }
3156
3157     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3158     $logger->info("circulator: transiting copy to $dest");
3159
3160     $transit->source($self->circ_lib);
3161     $transit->dest($dest);
3162     $transit->target_copy($copy->id);
3163     $transit->source_send_time('now');
3164     $transit->copy_status( $U->copy_status($copy->status)->id );
3165
3166     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3167
3168     if ($self->remote_hold) {
3169         return $self->bail_on_events($self->editor->event)
3170             unless $self->editor->create_action_hold_transit_copy($transit);
3171     } else {
3172         return $self->bail_on_events($self->editor->event)
3173             unless $self->editor->create_action_transit_copy($transit);
3174     }
3175
3176     # ensure the transit is returned to the caller
3177     $self->transit($transit);
3178
3179     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3180     $self->update_copy;
3181     $self->checkin_changed(1);
3182 }
3183
3184
3185 sub hold_capture_is_possible {
3186     my $self = shift;
3187     my $copy = $self->copy;
3188
3189     # we've been explicitly told not to capture any holds
3190     return 0 if $self->capture eq 'nocapture';
3191
3192     # See if this copy can fulfill any holds
3193     my $hold = $holdcode->find_nearest_permitted_hold(
3194         $self->editor, $copy, $self->editor->requestor, 1 # check_only
3195     );
3196     return undef if ref $hold eq "HASH" and
3197         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3198     return $hold;
3199 }
3200
3201 sub reservation_capture_is_possible {
3202     my $self = shift;
3203     my $copy = $self->copy;
3204
3205     # we've been explicitly told not to capture any holds
3206     return 0 if $self->capture eq 'nocapture';
3207
3208     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3209     my $resv = $booking_ses->request(
3210         "open-ils.booking.reservations.could_capture",
3211         $self->editor->authtoken, $copy->barcode
3212     )->gather(1);
3213     $booking_ses->disconnect;
3214     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3215         $self->push_events($resv);
3216     } else {
3217         return $resv;
3218     }
3219 }
3220
3221 # returns true if the item was used (or may potentially be used 
3222 # in subsequent calls) to capture a hold.
3223 sub attempt_checkin_hold_capture {
3224     my $self = shift;
3225     my $copy = $self->copy;
3226
3227     # we've been explicitly told not to capture any holds
3228     return 0 if $self->capture eq 'nocapture';
3229
3230     # See if this copy can fulfill any holds
3231     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
3232         $self->editor, $copy, $self->editor->requestor );
3233
3234     if(!$hold) {
3235         $logger->debug("circulator: no potential permitted".
3236             "holds found for copy ".$copy->barcode);
3237         return 0;
3238     }
3239
3240     if($self->capture ne 'capture') {
3241         # see if this item is in a hold-capture-delay location
3242         my $location = $self->copy->location;
3243         if(!ref($location)) {
3244             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3245             $self->copy->location($location);
3246         }
3247         if($U->is_true($location->hold_verify)) {
3248             $self->bail_on_events(
3249                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3250             return 1;
3251         }
3252     }
3253
3254     $self->retarget($retarget);
3255
3256     my $suppress_transit = 0;
3257     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3258         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3259         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3260             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3261             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3262                 $suppress_transit = 1;
3263                 $hold->pickup_lib($self->circ_lib);
3264             }
3265         }
3266     }
3267
3268     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3269
3270     $hold->current_copy($copy->id);
3271     $hold->capture_time('now');
3272     $self->put_hold_on_shelf($hold) 
3273         if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3274
3275     # prevent DB errors caused by fetching 
3276     # holds from storage, and updating through cstore
3277     $hold->clear_fulfillment_time;
3278     $hold->clear_fulfillment_staff;
3279     $hold->clear_fulfillment_lib;
3280     $hold->clear_expire_time; 
3281     $hold->clear_cancel_time;
3282     $hold->clear_prev_check_time unless $hold->prev_check_time;
3283
3284     $self->bail_on_events($self->editor->event)
3285         unless $self->editor->update_action_hold_request($hold);
3286     $self->hold($hold);
3287     $self->checkin_changed(1);
3288
3289     return 0 if $self->bail_out;
3290
3291     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3292
3293         if ($hold->hold_type eq 'R') {
3294             $copy->status(OILS_COPY_STATUS_CATALOGING);
3295             $hold->fulfillment_time('now');
3296             $self->noop(1); # Block other transit/hold checks
3297             $self->bail_on_events($self->editor->event)
3298                 unless $self->editor->update_action_hold_request($hold);
3299         } else {
3300             # This hold was captured in the correct location
3301             $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3302             $self->push_events(OpenILS::Event->new('SUCCESS'));
3303
3304             #$self->do_hold_notify($hold->id);
3305             $self->notify_hold($hold->id);
3306         }
3307
3308     } else {
3309     
3310         # Hold needs to be picked up elsewhere.  Build a hold
3311         # transit and route the item.
3312         $self->checkin_build_hold_transit();
3313         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3314         return 0 if $self->bail_out;
3315         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3316     }
3317
3318     # make sure we save the copy status
3319     $self->update_copy;
3320     return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3321     return 1;
3322 }
3323
3324 sub attempt_checkin_reservation_capture {
3325     my $self = shift;
3326     my $copy = $self->copy;
3327
3328     # we've been explicitly told not to capture any holds
3329     return 0 if $self->capture eq 'nocapture';
3330
3331     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3332     my $evt = $booking_ses->request(
3333         "open-ils.booking.resources.capture_for_reservation",
3334         $self->editor->authtoken,
3335         $copy->barcode,
3336         1 # don't update copy - we probably have it locked
3337     )->gather(1);
3338     $booking_ses->disconnect;
3339
3340     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3341         $logger->warn(
3342             "open-ils.booking.resources.capture_for_reservation " .
3343             "didn't return an event!"
3344         );
3345     } else {
3346         if (
3347             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3348             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3349         ) {
3350             # not-transferable is an error event we'll pass on the user
3351             $logger->warn("reservation capture attempted against non-transferable item");
3352             $self->push_events($evt);
3353             return 0;
3354         } elsif ($evt->{"textcode"} eq "SUCCESS") {
3355             # Re-retrieve copy as reservation capture may have changed
3356             # its status and whatnot.
3357             $logger->info(
3358                 "circulator: booking capture win on copy " . $self->copy->id
3359             );
3360             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3361                 $logger->info(
3362                     "circulator: changing copy " . $self->copy->id .
3363                     "'s status from " . $self->copy->status . " to " .
3364                     $new_copy_status
3365                 );
3366                 $self->copy->status($new_copy_status);
3367                 $self->update_copy;
3368             }
3369             $self->reservation($evt->{"payload"}->{"reservation"});
3370
3371             if (exists $evt->{"payload"}->{"transit"}) {
3372                 $self->push_events(
3373                     new OpenILS::Event(
3374                         "ROUTE_ITEM",
3375                         "org" => $evt->{"payload"}->{"transit"}->dest
3376                     )
3377                 );
3378             }
3379             $self->checkin_changed(1);
3380             return 1;
3381         }
3382     }
3383     # other results are treated as "nothing to capture"
3384     return 0;
3385 }
3386
3387 sub do_hold_notify {
3388     my( $self, $holdid ) = @_;
3389
3390     my $e = new_editor(xact => 1);
3391     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3392     $e->rollback;
3393     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3394     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3395
3396     $logger->info("circulator: running delayed hold notify process");
3397
3398 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3399 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3400
3401     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3402         hold_id => $holdid, requestor => $self->editor->requestor);
3403
3404     $logger->debug("circulator: built hold notifier");
3405
3406     if(!$notifier->event) {
3407
3408         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3409
3410         my $stat = $notifier->send_email_notify;
3411         if( $stat == '1' ) {
3412             $logger->info("circulator: hold notify succeeded for hold $holdid");
3413             return;
3414         } 
3415
3416         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3417
3418     } else {
3419         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3420     }
3421 }
3422
3423 sub retarget_holds {
3424     my $self = shift;
3425     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3426     my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3427     $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3428     # no reason to wait for the return value
3429     return;
3430 }
3431
3432 sub checkin_build_hold_transit {
3433     my $self = shift;
3434
3435    my $copy = $self->copy;
3436    my $hold = $self->hold;
3437    my $trans = Fieldmapper::action::hold_transit_copy->new;
3438
3439     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3440
3441    $trans->hold($hold->id);
3442    $trans->source($self->circ_lib);
3443    $trans->dest($hold->pickup_lib);
3444    $trans->source_send_time("now");
3445    $trans->target_copy($copy->id);
3446
3447     # when the copy gets to its destination, it will recover
3448     # this status - put it onto the holds shelf
3449    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3450
3451     return $self->bail_on_events($self->editor->event)
3452         unless $self->editor->create_action_hold_transit_copy($trans);
3453 }
3454
3455
3456
3457 sub process_received_transit {
3458     my $self = shift;
3459     my $copy = $self->copy;
3460     my $copyid = $self->copy->id;
3461
3462     my $status_name = $U->copy_status($copy->status)->name;
3463     $logger->debug("circulator: attempting transit receive on ".
3464         "copy $copyid. Copy status is $status_name");
3465
3466     my $transit = $self->transit;
3467
3468     # Check if we are in a transit suppress range
3469     my $suppress_transit = 0;
3470     if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3471         my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ?  'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3472         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3473         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3474             my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3475             if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3476                 $suppress_transit = 1;
3477                 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3478             }
3479         }
3480     }
3481     if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3482         # - this item is in-transit to a different location
3483         # - Or we are capturing holds as transits, so why create a new transit?
3484
3485         my $tid = $transit->id; 
3486         my $loc = $self->circ_lib;
3487         my $dest = $transit->dest;
3488
3489         $logger->info("circulator: Fowarding transit on copy which is destined ".
3490             "for a different location. transit=$tid, copy=$copyid, current ".
3491             "location=$loc, destination location=$dest");
3492
3493         my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3494
3495         # grab the associated hold object if available
3496         my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3497         $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3498
3499         return $self->bail_on_events($evt);
3500     }
3501
3502     # The transit is received, set the receive time
3503     $transit->dest_recv_time('now');
3504     $self->bail_on_events($self->editor->event)
3505         unless $self->editor->update_action_transit_copy($transit);
3506
3507     my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3508
3509     $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3510     $copy->status( $transit->copy_status );
3511     $self->update_copy();
3512     return if $self->bail_out;
3513
3514     my $ishold = 0;
3515     if($hold_transit) { 
3516         my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3517
3518         if ($hold) {
3519             # hold has arrived at destination, set shelf time
3520             $self->put_hold_on_shelf($hold);
3521             $self->bail_on_events($self->editor->event)
3522                 unless $self->editor->update_action_hold_request($hold);
3523             return if $self->bail_out;
3524
3525             $self->notify_hold($hold_transit->hold);
3526             $ishold = 1;
3527         } else {
3528             $hold_transit = undef;
3529             $self->cancelled_hold_transit(1);
3530             $self->reshelve_copy(1);
3531             $self->fake_hold_dest(0);
3532         }
3533     }
3534
3535     $self->push_events( 
3536         OpenILS::Event->new(
3537         'SUCCESS', 
3538         ishold => $ishold,
3539       payload => { transit => $transit, holdtransit => $hold_transit } ));
3540
3541     return $hold_transit;
3542 }
3543
3544
3545 # ------------------------------------------------------------------
3546 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3547 # ------------------------------------------------------------------
3548 sub put_hold_on_shelf {
3549     my($self, $hold) = @_;
3550     $hold->shelf_time('now');
3551     $hold->current_shelf_lib($self->circ_lib);
3552     $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3553     return undef;
3554 }
3555
3556 sub handle_fines {
3557    my $self = shift;
3558    my $reservation = shift;
3559    my $dt_parser = DateTime::Format::ISO8601->new;
3560
3561    my $obj = $reservation ? $self->reservation : $self->circ;
3562
3563     my $lost_bill_opts = $self->lost_bill_options;
3564     my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3565     # first, restore any voided overdues for lost, if needed
3566     if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3567         my $restore_od = $U->ou_ancestor_setting_value(
3568             $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3569             $self->editor) || 0;
3570         $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3571             if $restore_od;
3572     }
3573
3574     # next, handle normal overdue generation and apply stop_fines
3575     # XXX reservations don't have stop_fines
3576     # TODO revisit booking_reservation re: stop_fines support
3577     if ($reservation or !$obj->stop_fines) {
3578         my $skip_for_grace;
3579
3580         # This is a crude check for whether we are in a grace period. The code
3581         # in generate_fines() does a more thorough job, so this exists solely
3582         # as a small optimization, and might be better off removed.
3583
3584         # If we have a grace period
3585         if($obj->can('grace_period')) {
3586             # Parse out the due date
3587             my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3588             # Add the grace period to the due date
3589             $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3590             # Don't generate fines on circs still in grace period
3591             $skip_for_grace = $due_date > DateTime->now;
3592         }
3593         $CC->generate_fines({circs => [$obj], editor => $self->editor})
3594             unless $skip_for_grace;
3595
3596         if (!$reservation and !$obj->stop_fines) {
3597             $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3598             $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3599             $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3600             $obj->stop_fines_time('now');
3601             $obj->stop_fines_time($self->backdate) if $self->backdate;
3602             $self->editor->update_action_circulation($obj);
3603         }
3604     }
3605
3606     # finally, handle voiding of lost item and processing fees
3607     if ($self->needs_lost_bill_handling) {
3608         my $void_cost = $U->ou_ancestor_setting_value(
3609             $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3610             $self->editor) || 0;
3611         my $void_proc_fee = $U->ou_ancestor_setting_value(
3612             $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3613             $self->editor) || 0;
3614         $self->checkin_handle_lost_or_lo_now_found(
3615             $lost_bill_opts->{void_cost_btype},
3616             $lost_bill_opts->{is_longoverdue}) if $void_cost;
3617         $self->checkin_handle_lost_or_lo_now_found(
3618             $lost_bill_opts->{void_fee_btype},
3619             $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3620     }
3621
3622    return undef;
3623 }
3624
3625 sub checkin_handle_circ_start {
3626    my $self = shift;
3627    my $circ = $self->circ;
3628    my $copy = $self->copy;
3629    my $evt;
3630    my $obt;
3631
3632    $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3633
3634    # backdate the circ if necessary
3635    if($self->backdate) {
3636         my $evt = $self->checkin_handle_backdate;
3637         return $self->bail_on_events($evt) if $evt;
3638    }
3639
3640     # Set the checkin vars since we have the item
3641     $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3642
3643     # capture the true scan time for back-dated checkins
3644     $circ->checkin_scan_time('now');
3645
3646     $circ->checkin_staff($self->editor->requestor->id);
3647     $circ->checkin_lib($self->circ_lib);
3648     $circ->checkin_workstation($self->editor->requestor->wsid);
3649
3650     my $circ_lib = (ref $self->copy->circ_lib) ?  
3651         $self->copy->circ_lib->id : $self->copy->circ_lib;
3652     my $stat = $U->copy_status($self->copy->status)->id;
3653
3654     if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3655         # we will now handle lost fines, but the copy will retain its 'lost'
3656         # status if it needs to transit home unless lost_immediately_available
3657         # is true
3658         #
3659         # if we decide to also delay fine handling until the item arrives home,
3660         # we will need to call lost fine handling code both when checking items
3661         # in and also when receiving transits
3662         $self->checkin_handle_lost($circ_lib);
3663     } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3664         # same process as above.
3665         $self->checkin_handle_long_overdue($circ_lib);
3666     } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3667         $logger->info("circulator: not updating copy status on checkin because copy is missing");
3668     } else {
3669         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3670         $self->copy->status($U->copy_status($next_status));
3671         $self->update_copy;
3672     }
3673
3674     return undef;
3675 }
3676
3677 sub checkin_handle_circ_finish {
3678     my $self = shift;
3679     my $e = $self->editor;
3680     my $circ = $self->circ;
3681
3682     # Do one last check before the final circulation update to see 
3683     # if the xact_finish value should be set or not.
3684     #
3685     # The underlying money.billable_xact may have been updated to
3686     # reflect a change in xact_finish during checkin bills handling, 
3687     # however we can't simply refresh the circulation from the DB,
3688     # because other changes may be pending.  Instead, reproduce the
3689     # xact_finish check here.  It won't hurt to do it again.
3690
3691     my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3692     if ($sum) { # is this test still needed?
3693
3694         my $balance = $sum->balance_owed;
3695
3696         if ($balance == 0) {
3697             $circ->xact_finish('now');
3698         } else {
3699             $circ->clear_xact_finish;
3700         }
3701
3702         $logger->info("circulator: $balance is owed on this circulation");
3703     }
3704
3705     return $self->bail_on_events($e->event)
3706         unless $e->update_action_circulation($circ);
3707
3708     return undef;
3709 }
3710
3711 # ------------------------------------------------------------------
3712 # See if we need to void billings, etc. for lost checkin
3713 # ------------------------------------------------------------------
3714 sub checkin_handle_lost {
3715     my $self = shift;
3716     my $circ_lib = shift;
3717
3718     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3719         OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3720
3721     $self->lost_bill_options({
3722         circ_lib => $circ_lib,
3723         ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3724         ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3725         ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3726         void_cost_btype => 3, 
3727         void_fee_btype => 4 
3728     });
3729
3730     return $self->checkin_handle_lost_or_longoverdue(
3731         circ_lib => $circ_lib,
3732         max_return => $max_return,
3733         ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3734         ous_use_last_activity => undef # not supported for LOST checkin
3735     );
3736 }
3737
3738 # ------------------------------------------------------------------
3739 # See if we need to void billings, etc. for long-overdue checkin
3740 # note: not using constants below since they serve little purpose 
3741 # for single-use strings that are descriptive in their own right 
3742 # and mostly just complicate debugging.
3743 # ------------------------------------------------------------------
3744 sub checkin_handle_long_overdue {
3745     my $self = shift;
3746     my $circ_lib = shift;
3747
3748     $logger->info("circulator: processing long-overdue checkin...");
3749
3750     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3751         'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3752
3753     $self->lost_bill_options({
3754         circ_lib => $circ_lib,
3755         ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3756         ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3757         is_longoverdue => 1,
3758         ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3759         void_cost_btype => 10,
3760         void_fee_btype => 11
3761     });
3762
3763     return $self->checkin_handle_lost_or_longoverdue(
3764         circ_lib => $circ_lib,
3765         max_return => $max_return,
3766         ous_immediately_available => 'circ.longoverdue_immediately_available',
3767         ous_use_last_activity => 
3768             'circ.longoverdue.use_last_activity_date_on_return'
3769     )
3770 }
3771
3772 # last billing activity is last payment time, last billing time, or the 
3773 # circ due date.  If the relevant "use last activity" org unit setting is 
3774 # false/unset, then last billing activity is always the due date.
3775 sub get_circ_last_billing_activity {
3776     my $self = shift;
3777     my $circ_lib = shift;
3778     my $setting = shift;
3779     my $date = $self->circ->due_date;
3780
3781     return $date unless $setting and 
3782         $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3783
3784     my $xact = $self->editor->retrieve_money_billable_transaction([
3785         $self->circ->id,
3786         {flesh => 1, flesh_fields => {mbt => ['summary']}}
3787     ]);
3788
3789     if ($xact->summary) {
3790         $date = $xact->summary->last_payment_ts || 
3791                 $xact->summary->last_billing_ts || 
3792                 $self->circ->due_date;
3793     }
3794
3795     return $date;
3796 }
3797
3798
3799 sub checkin_handle_lost_or_longoverdue {
3800     my ($self, %args) = @_;
3801
3802     my $circ = $self->circ;
3803     my $max_return = $args{max_return};
3804     my $circ_lib = $args{circ_lib};
3805
3806     if ($max_return) {
3807
3808         my $last_activity = 
3809             $self->get_circ_last_billing_activity(
3810                 $circ_lib, $args{ous_use_last_activity});
3811
3812         my $today = time();
3813         my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3814         $tm[5] -= 1 if $tm[5] > 0;
3815         my $due = timelocal(int($tm[1]), int($tm[2]), 
3816             int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3817
3818         my $last_chance = 
3819             OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3820
3821         $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3822             "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3823                 "DUE: $due LAST: $last_chance");
3824
3825         $max_return = 0 if $today < $last_chance;
3826     }
3827
3828
3829     if ($max_return) {
3830
3831         $logger->info("circulator: check-in of lost/lo item exceeds max ". 
3832             "return interval.  skipping fine/fee voiding, etc.");
3833
3834     } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3835
3836         $logger->info("circulator: check-in of lost/lo item having a balance ".
3837             "of zero, skipping fine/fee voiding and reinstatement.");
3838
3839     } else { # within max-return interval or no interval defined
3840
3841         $logger->info("circulator: check-in of lost/lo item is within the ".
3842             "max return interval (or no interval is defined).  Proceeding ".
3843             "with fine/fee voiding, etc.");
3844
3845         $self->needs_lost_bill_handling(1);
3846     }
3847
3848     if ($circ_lib != $self->circ_lib) {
3849         # if the item is not home, check to see if we want to retain the
3850         # lost/longoverdue status at this point in the process
3851
3852         my $immediately_available = $U->ou_ancestor_setting_value($circ_lib, 
3853             $args{ous_immediately_available}, $self->editor) || 0;
3854
3855         if ($immediately_available) {
3856             # item status does not need to be retained, so give it a
3857             # reshelving status as if it were a normal checkin
3858             my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3859             $self->copy->status($U->copy_status($next_status));
3860             $self->update_copy;
3861         } else {
3862             $logger->info("circulator: leaving lost/longoverdue copy".
3863                 " status in place on checkin");
3864         }
3865     } else {
3866         # lost/longoverdue item is home and processed, treat like a normal 
3867         # checkin from this point on
3868         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3869         $self->copy->status($U->copy_status($next_status));
3870         $self->update_copy;
3871     }
3872 }
3873
3874
3875 sub checkin_handle_backdate {
3876     my $self = shift;
3877
3878     # ------------------------------------------------------------------
3879     # clean up the backdate for date comparison
3880     # XXX We are currently taking the due-time from the original due-date,
3881     # not the input.  Do we need to do this?  This certainly interferes with
3882     # backdating of hourly checkouts, but that is likely a very rare case.
3883     # ------------------------------------------------------------------
3884     my $bd = clean_ISO8601($self->backdate);
3885     my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3886     my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3887     $new_date->set_hour($original_date->hour());
3888     $new_date->set_minute($original_date->minute());
3889     if ($new_date >= DateTime->now) {
3890         # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3891         # $self->backdate() autoload handler ignores undef values.  
3892         # Clear the backdate manually.
3893         $logger->info("circulator: ignoring future backdate: $new_date");
3894         delete $self->{backdate};
3895     } else {
3896         $self->backdate(clean_ISO8601($new_date->datetime()));
3897     }
3898
3899     return undef;
3900 }
3901
3902
3903 sub check_checkin_copy_status {
3904     my $self = shift;
3905    my $copy = $self->copy;
3906
3907    my $status = $U->copy_status($copy->status)->id;
3908
3909    return undef
3910       if(   $self->new_copy_alerts ||
3911             $status == OILS_COPY_STATUS_AVAILABLE   ||
3912             $status == OILS_COPY_STATUS_CHECKED_OUT ||
3913             $status == OILS_COPY_STATUS_IN_PROCESS  ||
3914             $status == OILS_COPY_STATUS_ON_HOLDS_SHELF  ||
3915             $status == OILS_COPY_STATUS_IN_TRANSIT  ||
3916             $status == OILS_COPY_STATUS_CATALOGING  ||
3917             $status == OILS_COPY_STATUS_ON_RESV_SHELF  ||
3918             $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3919             $status == OILS_COPY_STATUS_RESHELVING );
3920
3921    return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3922       if( $status == OILS_COPY_STATUS_LOST );
3923
3924     return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3925         if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3926
3927    return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3928       if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3929
3930    return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3931       if( $status == OILS_COPY_STATUS_MISSING );
3932
3933    return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3934 }
3935
3936
3937
3938 # --------------------------------------------------------------------------
3939 # On checkin, we need to return as many relevant objects as we can
3940 # --------------------------------------------------------------------------
3941 sub checkin_flesh_events {
3942     my $self = shift;
3943
3944     if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events} 
3945         and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3946             $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3947     }
3948
3949     my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3950
3951     my $hold;
3952     if($self->hold and !$self->hold->cancel_time) {
3953         $hold = $self->hold;
3954         $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3955     }
3956
3957     if($self->circ) {
3958         # update our copy of the circ object and 
3959         # flesh the billing summary data
3960         $self->circ(
3961             $self->editor->retrieve_action_circulation([
3962                 $self->circ->id, {
3963                     flesh => 2,
3964                     flesh_fields => {
3965                         circ => ['billable_transaction'],
3966                         mbt => ['summary']
3967                     }
3968                 }
3969             ])
3970         );
3971     }
3972
3973     if($self->patron) {
3974         # flesh some patron fields before returning
3975         $self->patron(
3976             $self->editor->retrieve_actor_user([
3977                 $self->patron->id,
3978                 {
3979                     flesh => 1,
3980                     flesh_fields => {
3981                         au => ['card', 'billing_address', 'mailing_address']
3982                     }
3983                 }
3984             ])
3985         );
3986     }
3987
3988     if ($self->latest_inventory) {
3989         # flesh some workstation fields before returning
3990         $self->latest_inventory->inventory_workstation(
3991             $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
3992         );
3993     }
3994
3995     if($self->latest_inventory && !$self->latest_inventory->id) {
3996         my $alci = $self->editor->search_asset_latest_inventory(
3997             {copy => $self->latest_inventory->copy}
3998         );
3999         if($alci->[0]) {
4000             $self->latest_inventory->id($alci->[0]->id);
4001         }
4002     }
4003     $self->copy->latest_inventory($self->latest_inventory);
4004
4005     for my $evt (@{$self->events}) {
4006
4007         my $payload         = {};
4008         $payload->{copy}    = $U->unflesh_copy($self->copy);
4009         $payload->{volume}  = $self->volume;
4010         $payload->{record}  = $record,
4011         $payload->{circ}    = $self->circ;
4012         $payload->{transit} = $self->transit;
4013         $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4014         $payload->{hold}    = $hold;
4015         $payload->{patron}  = $self->patron;
4016         $payload->{reservation} = $self->reservation
4017             unless (not $self->reservation or $self->reservation->cancel_time);
4018         $payload->{latest_inventory} = $self->latest_inventory;
4019         if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4020
4021         $evt->{payload}     = $payload;
4022     }
4023 }
4024
4025 sub log_me {
4026     my( $self, $msg ) = @_;
4027     my $bc = ($self->copy) ? $self->copy->barcode :
4028         $self->barcode;
4029     $bc ||= "";
4030     my $usr = ($self->patron) ? $self->patron->id : "";
4031     $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4032         ", recipient=$usr, copy=$bc");
4033 }
4034
4035
4036 sub do_renew {
4037     my $self = shift;
4038     my $api = shift;
4039     $self->log_me("do_renew()");
4040
4041     # Make sure there is an open circ to renew
4042     my $usrid = $self->patron->id if $self->patron;
4043     my $circ = $self->editor->search_action_circulation({
4044         target_copy => $self->copy->id,
4045         xact_finish => undef,
4046         checkin_time => undef,
4047         ($usrid ? (usr => $usrid) : ())
4048     })->[0];
4049
4050     return $self->bail_on_events($self->editor->event) unless $circ;
4051
4052     # A user is not allowed to renew another user's items without permission
4053     unless( $circ->usr eq $self->editor->requestor->id ) {
4054         return $self->bail_on_events($self->editor->events)
4055             unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4056     }   
4057
4058     $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4059         if $circ->renewal_remaining < 1;
4060
4061     $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4062         if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4063     # -----------------------------------------------------------------
4064
4065     $self->parent_circ($circ->id);
4066     $self->renewal_remaining( $circ->renewal_remaining - 1 );
4067     $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4068     $self->circ($circ);
4069
4070     # Opac renewal - re-use circ library from original circ (unless told not to)
4071     if($self->opac_renewal or $self->auto_renewal) {
4072         unless(defined($opac_renewal_use_circ_lib)) {
4073             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4074             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4075                 $opac_renewal_use_circ_lib = 1;
4076             }
4077             else {
4078                 $opac_renewal_use_circ_lib = 0;
4079             }
4080         }
4081         $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4082     }
4083
4084     # Desk renewal - re-use circ library from original circ (unless told not to)
4085     if($self->desk_renewal) {
4086         unless(defined($desk_renewal_use_circ_lib)) {
4087             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4088             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4089                 $desk_renewal_use_circ_lib = 1;
4090             }
4091             else {
4092                 $desk_renewal_use_circ_lib = 0;
4093             }
4094         }
4095         $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4096     }
4097
4098     # Run the fine generator against the old circ
4099     # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4100     # a few lines down.  Commenting out, for now.
4101     #$self->handle_fines;
4102
4103     $self->run_renew_permit;
4104
4105     # Check the item in
4106     $self->do_checkin();
4107     return if $self->bail_out;
4108
4109     unless( $self->permit_override ) {
4110         $self->do_permit();
4111         return if $self->bail_out;
4112         $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4113         $self->remove_event('ITEM_NOT_CATALOGED');
4114     }   
4115
4116     $self->override_events;
4117     return if $self->bail_out;
4118
4119     $self->events([]);
4120     $self->do_checkout();
4121 }
4122
4123
4124 sub remove_event {
4125     my( $self, $evt ) = @_;
4126     $evt = (ref $evt) ? $evt->{textcode} : $evt;
4127     $logger->debug("circulator: removing event from list: $evt");
4128     my @events = @{$self->events};
4129     $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4130 }
4131
4132
4133 sub have_event {
4134     my( $self, $evt ) = @_;
4135     $evt = (ref $evt) ? $evt->{textcode} : $evt;
4136     return grep { $_->{textcode} eq $evt } @{$self->events};
4137 }
4138
4139
4140 sub run_renew_permit {
4141     my $self = shift;
4142
4143     if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4144         my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4145             $self->editor, $self->copy, $self->editor->requestor, 1
4146         );
4147         $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4148     }
4149
4150     my $results = $self->run_indb_circ_test;
4151     $self->push_events($self->matrix_test_result_events)
4152         unless $self->circ_test_success;
4153 }
4154
4155
4156 # XXX: The primary mechanism for storing circ history is now handled
4157 # by tracking real circulation objects instead of bibs in a bucket.
4158 # However, this code is disabled by default and could be useful 
4159 # some day, so may as well leave it for now.
4160 sub append_reading_list {
4161     my $self = shift;
4162
4163     return undef unless 
4164         $self->is_checkout and 
4165         $self->patron and 
4166         $self->copy and 
4167         !$self->is_noncat;
4168
4169
4170     # verify history is globally enabled and uses the bucket mechanism
4171     my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4172         apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4173
4174     return undef unless $htype and $htype eq 'bucket';
4175
4176     my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4177
4178     # verify the patron wants to retain the hisory
4179     my $setting = $e->search_actor_user_setting(
4180         {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4181     
4182     unless($setting and $setting->value) {
4183         $e->rollback;
4184         return undef;
4185     }
4186
4187     my $bkt = $e->search_container_copy_bucket(
4188         {owner => $self->patron->id, btype => 'circ_history'})->[0];
4189
4190     my $pos = 1;
4191
4192     if($bkt) {
4193         # find the next item position
4194         my $last_item = $e->search_container_copy_bucket_item(
4195             {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4196         $pos = $last_item->pos + 1 if $last_item;
4197
4198     } else {
4199         # create the history bucket if necessary
4200         $bkt = Fieldmapper::container::copy_bucket->new;
4201         $bkt->owner($self->patron->id);
4202         $bkt->name('');
4203         $bkt->btype('circ_history');
4204         $bkt->pub('f');
4205         $e->create_container_copy_bucket($bkt) or return $e->die_event;
4206     }
4207
4208     my $item = Fieldmapper::container::copy_bucket_item->new;
4209
4210     $item->bucket($bkt->id);
4211     $item->target_copy($self->copy->id);
4212     $item->pos($pos);
4213
4214     $e->create_container_copy_bucket_item($item) or return $e->die_event;
4215     $e->commit;
4216
4217     return undef;
4218 }
4219
4220
4221 sub make_trigger_events {
4222     my $self = shift;
4223     return unless $self->circ;
4224     $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4225     $U->create_events_for_hook('checkin',  $self->circ, $self->circ_lib) if $self->is_checkin;
4226     $U->create_events_for_hook('renewal',  $self->circ, $self->circ_lib) if $self->is_renewal;
4227 }
4228
4229
4230
4231 sub checkin_handle_lost_or_lo_now_found {
4232     my ($self, $bill_type, $is_longoverdue) = @_;
4233
4234     my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4235
4236     $logger->debug("voiding $tag item billings");
4237     my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4238     $self->bail_on_events($self->editor->event) if ($result);
4239 }
4240
4241 sub checkin_handle_lost_or_lo_now_found_restore_od {
4242     my $self = shift;
4243     my $circ_lib = shift;
4244     my $is_longoverdue = shift;
4245     my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4246
4247     # ------------------------------------------------------------------
4248     # restore those overdue charges voided when item was set to lost
4249     # ------------------------------------------------------------------
4250
4251     my $ods = $self->editor->search_money_billing([
4252         {
4253             xact => $self->circ->id,
4254             btype => 1
4255         },
4256         {
4257             order_by => {mb => 'billing_ts desc'}
4258         }
4259     ]);
4260
4261     $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4262     # Because actual users get up to all kinds of unexpectedness, we
4263     # only recreate up to $circ->max_fine in bills.  I know you think
4264     # it wouldn't happen that bills could get created, voided, and
4265     # recreated more than once, but I guaran-damn-tee you that it will
4266     # happen.
4267     if ($ods && @$ods) {
4268         my $void_amount = 0;
4269         my $void_max = $self->circ->max_fine();
4270         # search for overdues voided the new way (aka "adjusted")
4271         my @billings = map {$_->id()} @$ods;
4272         my $voids = $self->editor->search_money_account_adjustment(
4273             {
4274                 billing => \@billings
4275             }
4276         );
4277         if (@$voids) {
4278             map {$void_amount += $_->amount()} @$voids;
4279         } else {
4280             # if no adjustments found, assume they were voided the old way (aka "voided")
4281             for my $bill (@$ods) {
4282                 if( $U->is_true($bill->voided) ) {
4283                     $void_amount += $bill->amount();
4284                 }
4285             }
4286         }
4287         $CC->create_bill(
4288             $self->editor,
4289             ($void_amount < $void_max ? $void_amount : $void_max),
4290             $ods->[0]->btype(),
4291             $ods->[0]->billing_type(),
4292             $self->circ->id(),
4293             "System: $tag RETURNED - OVERDUES REINSTATED",
4294             $ods->[-1]->period_start(),
4295             $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)
4296         );
4297     }
4298 }
4299
4300 1;