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