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