be5970cbd453c925314836586d9fdea611a480df
[koha.git] / Koha / Hold.pm
1 package Koha::Hold;
2
3 # Copyright ByWater Solutions 2014
4 # Copyright 2017 Koha Development team
5 #
6 # This file is part of Koha.
7 #
8 # Koha is free software; you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # Koha is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Koha; if not, see <http://www.gnu.org/licenses>.
20
21 use Modern::Perl;
22
23 use List::MoreUtils qw( any );
24
25 use C4::Context;
26 use C4::Letters qw( GetPreparedLetter EnqueueLetter );
27 use C4::Log qw( logaction );
28
29 use Koha::AuthorisedValues;
30 use Koha::DateUtils qw( dt_from_string );
31 use Koha::Patrons;
32 use Koha::Biblios;
33 use Koha::Items;
34 use Koha::Libraries;
35 use Koha::Old::Holds;
36 use Koha::Calendar;
37 use Koha::Plugins;
38
39 use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
40
41 use Koha::Exceptions::Hold;
42
43 use base qw(Koha::Object);
44
45 =head1 NAME
46
47 Koha::Hold - Koha Hold object class
48
49 =head1 API
50
51 =head2 Class methods
52
53 =cut
54
55 =head3 age
56
57 returns the number of days since a hold was placed, optionally
58 using the calendar
59
60 my $age = $hold->age( $use_calendar );
61
62 =cut
63
64 sub age {
65     my ( $self, $use_calendar ) = @_;
66
67     my $today = dt_from_string;
68     my $age;
69
70     if ( $use_calendar ) {
71         my $calendar = Koha::Calendar->new( branchcode => $self->branchcode );
72         $age = $calendar->days_between( dt_from_string( $self->reservedate ), $today );
73     }
74     else {
75         $age = $today->delta_days( dt_from_string( $self->reservedate ) );
76     }
77
78     $age = $age->in_units( 'days' );
79
80     return $age;
81 }
82
83 =head3 suspend_hold
84
85 my $hold = $hold->suspend_hold( $suspend_until_dt );
86
87 =cut
88
89 sub suspend_hold {
90     my ( $self, $dt ) = @_;
91
92     my $date = $dt ? $dt->clone()->truncate( to => 'day' )->datetime : undef;
93
94     if ( $self->is_found ) {    # We can't suspend found holds
95         if ( $self->is_waiting ) {
96             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'W' );
97         }
98         elsif ( $self->is_in_transit ) {
99             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'T' );
100         }
101         elsif ( $self->is_in_processing ) {
102             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'P' );
103         }
104         else {
105             Koha::Exceptions::Hold::CannotSuspendFound->throw(
106                       'Unhandled data exception on found hold (id='
107                     . $self->id
108                     . ', found='
109                     . $self->found
110                     . ')' );
111         }
112     }
113
114     $self->suspend(1);
115     $self->suspend_until($date);
116     $self->store();
117
118     Koha::Plugins->call(
119         'after_hold_action',
120         {
121             action  => 'suspend',
122             payload => { hold => $self->get_from_storage }
123         }
124     );
125
126     logaction( 'HOLDS', 'SUSPEND', $self->reserve_id, $self )
127         if C4::Context->preference('HoldsLog');
128
129     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
130         {
131             biblio_ids => [ $self->biblionumber ]
132         }
133     ) if C4::Context->preference('RealTimeHoldsQueue');
134
135     return $self;
136 }
137
138 =head3 resume
139
140 my $hold = $hold->resume();
141
142 =cut
143
144 sub resume {
145     my ( $self ) = @_;
146
147     $self->suspend(0);
148     $self->suspend_until( undef );
149
150     $self->store();
151
152     Koha::Plugins->call(
153         'after_hold_action',
154         {
155             action  => 'resume',
156             payload => { hold => $self->get_from_storage }
157         }
158     );
159
160     logaction( 'HOLDS', 'RESUME', $self->reserve_id, $self )
161         if C4::Context->preference('HoldsLog');
162
163     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
164         {
165             biblio_ids => [ $self->biblionumber ]
166         }
167     ) if C4::Context->preference('RealTimeHoldsQueue');
168
169     return $self;
170 }
171
172 =head3 delete
173
174 $hold->delete();
175
176 =cut
177
178 sub delete {
179     my ( $self ) = @_;
180
181     my $deleted = $self->SUPER::delete($self);
182
183     logaction( 'HOLDS', 'DELETE', $self->reserve_id, $self )
184         if C4::Context->preference('HoldsLog');
185
186     return $deleted;
187 }
188
189 =head3 set_transfer
190
191 =cut
192
193 sub set_transfer {
194     my ( $self ) = @_;
195
196     $self->priority(0);
197     $self->found('T');
198     $self->store();
199
200     return $self;
201 }
202
203 =head3 set_waiting
204
205 =cut
206
207 sub set_waiting {
208     my ( $self, $desk_id ) = @_;
209
210     $self->priority(0);
211
212     my $today = dt_from_string();
213
214     my $values = {
215         found => 'W',
216         ( !$self->waitingdate ? ( waitingdate => $today->ymd ) : () ),
217         desk_id => $desk_id,
218     };
219
220     my $max_pickup_delay = C4::Context->preference("ReservesMaxPickUpDelay");
221     my $cancel_on_holidays = C4::Context->preference('ExpireReservesOnHolidays');
222
223     my $new_expiration_date = $today->clone->add(days => $max_pickup_delay);
224
225     if ( C4::Context->preference("ExcludeHolidaysFromMaxPickUpDelay") ) {
226         my $itemtype = $self->item ? $self->item->effective_itemtype : $self->biblio->itemtype;
227         my $daysmode = Koha::CirculationRules->get_effective_daysmode(
228             {
229                 categorycode => $self->borrower->categorycode,
230                 itemtype     => $itemtype,
231                 branchcode   => $self->branchcode,
232             }
233         );
234         my $calendar = Koha::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode );
235
236         $new_expiration_date = $calendar->days_forward( dt_from_string(), $max_pickup_delay );
237     }
238
239     # If patron's requested expiration date is prior to the
240     # calculated one, we keep the patron's one.
241     if ( $self->patron_expiration_date ) {
242         my $requested_expiration = dt_from_string( $self->patron_expiration_date );
243
244         my $cmp =
245           $requested_expiration
246           ? DateTime->compare( $requested_expiration, $new_expiration_date )
247           : 0;
248
249         $new_expiration_date =
250           $cmp == -1 ? $requested_expiration : $new_expiration_date;
251     }
252
253     $values->{expirationdate} = $new_expiration_date->ymd;
254
255     $self->set($values)->store();
256
257     return $self;
258 }
259
260 =head3 is_pickup_location_valid
261
262     if ($hold->is_pickup_location_valid({ library_id => $library->id }) ) {
263         ...
264     }
265
266 Returns a I<boolean> representing if the passed pickup location is valid for the hold.
267 It throws a I<Koha::Exceptions::_MissingParameter> if the library_id parameter is not
268 passed.
269
270 =cut
271
272 sub is_pickup_location_valid {
273     my ( $self, $params ) = @_;
274
275     Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
276         unless $params->{library_id};
277
278     my $pickup_locations;
279
280     if ( $self->itemnumber ) { # item-level
281         $pickup_locations = $self->item->pickup_locations({ patron => $self->patron });
282     }
283     else { # biblio-level
284         $pickup_locations = $self->biblio->pickup_locations({ patron => $self->patron });
285     }
286
287     return any { $_->branchcode eq $params->{library_id} } $pickup_locations->as_list;
288 }
289
290 =head3 set_pickup_location
291
292     $hold->set_pickup_location(
293         {
294             library_id => $library->id,
295           [ force   => 0|1 ]
296         }
297     );
298
299 Updates the hold pickup location. It throws a I<Koha::Exceptions::Hold::InvalidPickupLocation> if
300 the passed pickup location is not valid.
301
302 Note: It is up to the caller to verify if I<AllowHoldPolicyOverride> is set when setting the
303 B<force> parameter.
304
305 =cut
306
307 sub set_pickup_location {
308     my ( $self, $params ) = @_;
309
310     Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
311         unless $params->{library_id};
312
313     if (
314         $params->{force}
315         || $self->is_pickup_location_valid(
316             { library_id => $params->{library_id} }
317         )
318       )
319     {
320         # all good, set the new pickup location
321         $self->branchcode( $params->{library_id} )->store;
322     }
323     else {
324         Koha::Exceptions::Hold::InvalidPickupLocation->throw;
325     }
326
327     return $self;
328 }
329
330 =head3 set_processing
331
332 $hold->set_processing;
333
334 Mark the hold as in processing.
335
336 =cut
337
338 sub set_processing {
339     my ( $self ) = @_;
340
341     $self->priority(0);
342     $self->found('P');
343     $self->store();
344
345     return $self;
346 }
347
348 =head3 is_found
349
350 Returns true if hold is waiting, in transit or in processing
351
352 =cut
353
354 sub is_found {
355     my ($self) = @_;
356
357     return 0 unless $self->found();
358     return 1 if $self->found() eq 'W';
359     return 1 if $self->found() eq 'T';
360     return 1 if $self->found() eq 'P';
361 }
362
363 =head3 is_waiting
364
365 Returns true if hold is a waiting hold
366
367 =cut
368
369 sub is_waiting {
370     my ($self) = @_;
371
372     my $found = $self->found;
373     return $found && $found eq 'W';
374 }
375
376 =head3 is_in_transit
377
378 Returns true if hold is a in_transit hold
379
380 =cut
381
382 sub is_in_transit {
383     my ($self) = @_;
384
385     return 0 unless $self->found();
386     return $self->found() eq 'T';
387 }
388
389 =head3 is_in_processing
390
391 Returns true if hold is a in_processing hold
392
393 =cut
394
395 sub is_in_processing {
396     my ($self) = @_;
397
398     return 0 unless $self->found();
399     return $self->found() eq 'P';
400 }
401
402 =head3 is_cancelable_from_opac
403
404 Returns true if hold is a cancelable hold
405
406 Holds may be only canceled if they are not found.
407
408 This is used from the OPAC.
409
410 =cut
411
412 sub is_cancelable_from_opac {
413     my ($self) = @_;
414
415     return 1 unless $self->is_found();
416     return 0; # if ->is_in_transit or if ->is_waiting or ->is_in_processing
417 }
418
419 =head3 is_at_destination
420
421 Returns true if hold is waiting
422 and the hold's pickup branch matches
423 the hold item's holding branch
424
425 =cut
426
427 sub is_at_destination {
428     my ($self) = @_;
429
430     return $self->is_waiting() && ( $self->branchcode() eq $self->item()->holdingbranch() );
431 }
432
433 =head3 biblio
434
435 Returns the related Koha::Biblio object for this hold
436
437 =cut
438
439 sub biblio {
440     my ($self) = @_;
441
442     $self->{_biblio} ||= Koha::Biblios->find( $self->biblionumber() );
443
444     return $self->{_biblio};
445 }
446
447 =head3 patron
448
449 Returns the related Koha::Patron object for this hold
450
451 =cut
452
453 sub patron {
454     my ($self) = @_;
455
456     my $patron_rs = $self->_result->patron;
457     return Koha::Patron->_new_from_dbic($patron_rs);
458 }
459
460 =head3 item
461
462 Returns the related Koha::Item object for this Hold
463
464 =cut
465
466 sub item {
467     my ($self) = @_;
468
469     $self->{_item} ||= Koha::Items->find( $self->itemnumber() );
470
471     return $self->{_item};
472 }
473
474 =head3 branch
475
476 Returns the related Koha::Library object for this Hold
477
478 =cut
479
480 sub branch {
481     my ($self) = @_;
482
483     $self->{_branch} ||= Koha::Libraries->find( $self->branchcode() );
484
485     return $self->{_branch};
486 }
487
488 =head3 desk
489
490 Returns the related Koha::Desk object for this Hold
491
492 =cut
493
494 sub desk {
495     my $self = shift;
496     my $desk_rs = $self->_result->desk;
497     return unless $desk_rs;
498     return Koha::Desk->_new_from_dbic($desk_rs);
499 }
500
501 =head3 borrower
502
503 Returns the related Koha::Patron object for this Hold
504
505 =cut
506
507 # FIXME Should be renamed with ->patron
508 sub borrower {
509     my ($self) = @_;
510
511     $self->{_borrower} ||= Koha::Patrons->find( $self->borrowernumber() );
512
513     return $self->{_borrower};
514 }
515
516 =head3 is_suspended
517
518 my $bool = $hold->is_suspended();
519
520 =cut
521
522 sub is_suspended {
523     my ( $self ) = @_;
524
525     return $self->suspend();
526 }
527
528
529 =head3 cancel
530
531 my $cancel_hold = $hold->cancel(
532     {
533         [ charge_cancel_fee   => 1||0, ]
534         [ cancellation_reason => $cancellation_reason, ]
535         [ skip_holds_queue    => 1||0 ]
536     }
537 );
538
539 Cancel a hold:
540 - The hold will be moved to the old_reserves table with a priority=0
541 - The priority of other holds will be updated
542 - The patron will be charge (see ExpireReservesMaxPickUpDelayCharge) if the charge_cancel_fee parameter is set
543 - The canceled hold will have the cancellation reason added to old_reserves.cancellation_reason if one is passed in
544 - a CANCEL HOLDS log will be done if the pref HoldsLog is on
545
546 =cut
547
548 sub cancel {
549     my ( $self, $params ) = @_;
550     $self->_result->result_source->schema->txn_do(
551         sub {
552             my $patron = $self->patron;
553
554             $self->cancellationdate( dt_from_string->strftime( '%Y-%m-%d %H:%M:%S' ) );
555             $self->priority(0);
556             $self->cancellation_reason( $params->{cancellation_reason} );
557             $self->store();
558
559             if ( $params->{cancellation_reason} ) {
560                 my $letter = C4::Letters::GetPreparedLetter(
561                     module                 => 'reserves',
562                     letter_code            => 'HOLD_CANCELLATION',
563                     message_transport_type => 'email',
564                     branchcode             => $self->borrower->branchcode,
565                     lang                   => $self->borrower->lang,
566                     tables => {
567                         branches    => $self->borrower->branchcode,
568                         borrowers   => $self->borrowernumber,
569                         items       => $self->itemnumber,
570                         biblio      => $self->biblionumber,
571                         biblioitems => $self->biblionumber,
572                         reserves    => $self->unblessed,
573                     }
574                 );
575
576                 if ($letter) {
577                     C4::Letters::EnqueueLetter(
578                         {
579                             letter                   => $letter,
580                             borrowernumber         => $self->borrowernumber,
581                             message_transport_type => 'email',
582                         }
583                     );
584                 }
585             }
586
587             my $old_me = $self->_move_to_old;
588
589             Koha::Plugins->call(
590                 'after_hold_action',
591                 {
592                     action  => 'cancel',
593                     payload => { hold => $old_me->get_from_storage }
594                 }
595             );
596
597             # anonymize if required
598             $old_me->anonymize
599                 if $patron->privacy == 2;
600
601             $self->SUPER::delete(); # Do not add a DELETE log
602
603             # now fix the priority on the others....
604             C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber });
605
606             # and, if desired, charge a cancel fee
607             my $charge = C4::Context->preference("ExpireReservesMaxPickUpDelayCharge");
608             if ( $charge && $params->{'charge_cancel_fee'} ) {
609                 my $account =
610                   Koha::Account->new( { patron_id => $self->borrowernumber } );
611                 $account->add_debit(
612                     {
613                         amount     => $charge,
614                         user_id    => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
615                         interface  => C4::Context->interface,
616                         library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
617                         type       => 'RESERVE_EXPIRED',
618                         item_id    => $self->itemnumber
619                     }
620                 );
621             }
622
623             C4::Log::logaction( 'HOLDS', 'CANCEL', $self->reserve_id, $self )
624                 if C4::Context->preference('HoldsLog');
625
626             Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
627                 {
628                     biblio_ids => [ $old_me->biblionumber ]
629                 }
630             ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
631         }
632     );
633     return $self;
634 }
635
636 =head3 fill
637
638     $hold->fill;
639
640 This method marks the hold as filled. It effectively moves it to old_reserves.
641
642 =cut
643
644 sub fill {
645     my ( $self ) = @_;
646     $self->_result->result_source->schema->txn_do(
647         sub {
648             my $patron = $self->patron;
649
650             $self->set(
651                 {
652                     found    => 'F',
653                     priority => 0,
654                 }
655             );
656
657             my $old_me = $self->_move_to_old;
658
659             Koha::Plugins->call(
660                 'after_hold_action',
661                 {
662                     action  => 'fill',
663                     payload => { hold => $old_me->get_from_storage }
664                 }
665             );
666
667             # anonymize if required
668             $old_me->anonymize
669                 if $patron->privacy == 2;
670
671             $self->SUPER::delete(); # Do not add a DELETE log
672
673             # now fix the priority on the others....
674             C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber });
675
676             if ( C4::Context->preference('HoldFeeMode') eq 'any_time_is_collected' ) {
677                 my $fee = $patron->category->reservefee // 0;
678                 if ( $fee > 0 ) {
679                     $patron->account->add_debit(
680                         {
681                             amount       => $fee,
682                             description  => $self->biblio->title,
683                             user_id      => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
684                             library_id   => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
685                             interface    => C4::Context->interface,
686                             type         => 'RESERVE',
687                             item_id      => $self->itemnumber
688                         }
689                     );
690                 }
691             }
692
693             C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self )
694                 if C4::Context->preference('HoldsLog');
695
696             Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
697                 {
698                     biblio_ids => [ $old_me->biblionumber ]
699                 }
700             ) if C4::Context->preference('RealTimeHoldsQueue');
701         }
702     );
703     return $self;
704 }
705
706 =head3 store
707
708 Override base store method to set default
709 expirationdate for holds.
710
711 =cut
712
713 sub store {
714     my ($self) = @_;
715
716     if ( !$self->in_storage ) {
717         if ( ! $self->expirationdate && $self->patron_expiration_date ) {
718             $self->expirationdate($self->patron_expiration_date);
719         }
720
721         if (
722             C4::Context->preference('DefaultHoldExpirationdate')
723                 && !$self->expirationdate
724           )
725         {
726             $self->_set_default_expirationdate;
727         }
728     }
729     else {
730
731         my %updated_columns = $self->_result->get_dirty_columns;
732         return $self->SUPER::store unless %updated_columns;
733
734         if ( exists $updated_columns{reservedate} ) {
735             if (
736                 C4::Context->preference('DefaultHoldExpirationdate')
737                 && ! exists $updated_columns{expirationdate}
738               )
739             {
740                 $self->_set_default_expirationdate;
741             }
742         }
743     }
744
745     $self = $self->SUPER::store;
746 }
747
748 sub _set_default_expirationdate {
749     my $self = shift;
750
751     my $period = C4::Context->preference('DefaultHoldExpirationdatePeriod') || 0;
752     my $timeunit =
753       C4::Context->preference('DefaultHoldExpirationdateUnitOfTime') || 'days';
754
755     $self->expirationdate(
756         dt_from_string( $self->reservedate )->add( $timeunit => $period ) );
757 }
758
759 =head3 _move_to_old
760
761 my $is_moved = $hold->_move_to_old;
762
763 Move a hold to the old_reserve table following the same pattern as Koha::Patron->move_to_deleted
764
765 =cut
766
767 sub _move_to_old {
768     my ($self) = @_;
769     my $hold_infos = $self->unblessed;
770     return Koha::Old::Hold->new( $hold_infos )->store;
771 }
772
773 =head3 to_api_mapping
774
775 This method returns the mapping for representing a Koha::Hold object
776 on the API.
777
778 =cut
779
780 sub to_api_mapping {
781     return {
782         reserve_id       => 'hold_id',
783         borrowernumber   => 'patron_id',
784         reservedate      => 'hold_date',
785         biblionumber     => 'biblio_id',
786         branchcode       => 'pickup_library_id',
787         notificationdate => undef,
788         reminderdate     => undef,
789         cancellationdate => 'cancellation_date',
790         reservenotes     => 'notes',
791         found            => 'status',
792         itemnumber       => 'item_id',
793         waitingdate      => 'waiting_date',
794         expirationdate   => 'expiration_date',
795         patron_expiration_date => undef,
796         lowestPriority   => 'lowest_priority',
797         suspend          => 'suspended',
798         suspend_until    => 'suspended_until',
799         itemtype         => 'item_type',
800         item_level_hold  => 'item_level',
801     };
802 }
803
804 =head2 Internal methods
805
806 =head3 _type
807
808 =cut
809
810 sub _type {
811     return 'Reserve';
812 }
813
814 =head1 AUTHORS
815
816 Kyle M Hall <kyle@bywatersolutions.com>
817 Jonathan Druart <jonathan.druart@bugs.koha-community.org>
818 Martin Renvoize <martin.renvoize@ptfs-europe.com>
819
820 =cut
821
822 1;