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