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