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