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