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