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