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