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