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