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