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