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