Bug 8367: (follow-up) Fix pickup delay text on OPAC and other bits
[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 qw(preference);
26 use C4::Letters qw( GetPreparedLetter EnqueueLetter );
27 use C4::Log qw( logaction );
28 use C4::Reserves;
29
30 use Koha::AuthorisedValues;
31 use Koha::DateUtils qw( dt_from_string );
32 use Koha::Patrons;
33 use Koha::Biblios;
34 use Koha::Hold::CancellationRequests;
35 use Koha::Items;
36 use Koha::Libraries;
37 use Koha::Old::Holds;
38 use Koha::Calendar;
39 use Koha::Plugins;
40
41 use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
42
43 use Koha::Exceptions;
44 use Koha::Exceptions::Hold;
45
46 use base qw(Koha::Object);
47
48 =head1 NAME
49
50 Koha::Hold - Koha Hold object class
51
52 =head1 API
53
54 =head2 Class methods
55
56 =cut
57
58 =head3 age
59
60 returns the number of days since a hold was placed, optionally
61 using the calendar
62
63 my $age = $hold->age( $use_calendar );
64
65 =cut
66
67 sub age {
68     my ( $self, $use_calendar ) = @_;
69
70     my $today = dt_from_string;
71     my $age;
72
73     if ( $use_calendar ) {
74         my $calendar = Koha::Calendar->new( branchcode => $self->branchcode );
75         $age = $calendar->days_between( dt_from_string( $self->reservedate ), $today );
76     }
77     else {
78         $age = $today->delta_days( dt_from_string( $self->reservedate ) );
79     }
80
81     $age = $age->in_units( 'days' );
82
83     return $age;
84 }
85
86 =head3 suspend_hold
87
88 my $hold = $hold->suspend_hold( $suspend_until );
89
90 =cut
91
92 sub suspend_hold {
93     my ( $self, $date ) = @_;
94
95     $date &&= dt_from_string($date)->truncate( to => 'day' )->datetime;
96
97     if ( $self->is_found ) {    # We can't suspend found holds
98         if ( $self->is_waiting ) {
99             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'W' );
100         }
101         elsif ( $self->is_in_transit ) {
102             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'T' );
103         }
104         elsif ( $self->is_in_processing ) {
105             Koha::Exceptions::Hold::CannotSuspendFound->throw( status => 'P' );
106         }
107         else {
108             Koha::Exceptions::Hold::CannotSuspendFound->throw(
109                       'Unhandled data exception on found hold (id='
110                     . $self->id
111                     . ', found='
112                     . $self->found
113                     . ')' );
114         }
115     }
116
117     $self->suspend(1);
118     $self->suspend_until($date);
119     $self->store();
120
121     Koha::Plugins->call(
122         'after_hold_action',
123         {
124             action  => 'suspend',
125             payload => { hold => $self->get_from_storage }
126         }
127     );
128
129     logaction( 'HOLDS', 'SUSPEND', $self->reserve_id, $self )
130         if C4::Context->preference('HoldsLog');
131
132     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
133         {
134             biblio_ids => [ $self->biblionumber ]
135         }
136     ) if C4::Context->preference('RealTimeHoldsQueue');
137
138     return $self;
139 }
140
141 =head3 resume
142
143 my $hold = $hold->resume();
144
145 =cut
146
147 sub resume {
148     my ( $self ) = @_;
149
150     $self->suspend(0);
151     $self->suspend_until( undef );
152
153     $self->store();
154
155     Koha::Plugins->call(
156         'after_hold_action',
157         {
158             action  => 'resume',
159             payload => { hold => $self->get_from_storage }
160         }
161     );
162
163     logaction( 'HOLDS', 'RESUME', $self->reserve_id, $self )
164         if C4::Context->preference('HoldsLog');
165
166     Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
167         {
168             biblio_ids => [ $self->biblionumber ]
169         }
170     ) if C4::Context->preference('RealTimeHoldsQueue');
171
172     return $self;
173 }
174
175 =head3 delete
176
177 $hold->delete();
178
179 =cut
180
181 sub delete {
182     my ( $self ) = @_;
183
184     my $deleted = $self->SUPER::delete($self);
185
186     logaction( 'HOLDS', 'DELETE', $self->reserve_id, $self )
187         if C4::Context->preference('HoldsLog');
188
189     return $deleted;
190 }
191
192 =head3 set_transfer
193
194 =cut
195
196 sub set_transfer {
197     my ( $self ) = @_;
198
199     $self->priority(0);
200     $self->found('T');
201     $self->store();
202
203     Koha::Plugins->call(
204         'after_hold_action',
205         {
206             action  => 'transfer',
207             payload => { hold => $self->get_from_storage }
208         }
209     );
210
211     return $self;
212 }
213
214 =head3 set_waiting
215
216 =cut
217
218 sub set_waiting {
219     my ( $self, $desk_id ) = @_;
220
221     $self->priority(0);
222
223     my $today = dt_from_string();
224
225     my $values = {
226         found => 'W',
227         ( !$self->waitingdate ? ( waitingdate => $today->ymd ) : () ),
228         desk_id => $desk_id,
229     };
230
231     my $max_pickup_delay = C4::Context->preference("ReservesMaxPickUpDelay");
232     my $cancel_on_holidays = C4::Context->preference('ExpireReservesOnHolidays');
233
234     my $rule = Koha::CirculationRules->get_effective_rule({
235         categorycode => $self->borrower->categorycode,
236         itemtype => $self->item->effective_itemtype,
237         branchcode => $self->branchcode,
238         rule_name => 'holds_pickup_period',
239     });
240     if ( defined($rule) and $rule->rule_value ne '' ){
241         # circulation rule overrides ReservesMaxPickUpDelay
242         $max_pickup_delay = $rule->rule_value;
243     }
244
245     my $new_expiration_date = dt_from_string($self->waitingdate)->clone->add( days => $max_pickup_delay );
246
247     if ( C4::Context->preference("ExcludeHolidaysFromMaxPickUpDelay") ) {
248         my $itemtype = $self->item ? $self->item->effective_itemtype : $self->biblio->itemtype;
249         my $daysmode = Koha::CirculationRules->get_effective_daysmode(
250             {
251                 categorycode => $self->borrower->categorycode,
252                 itemtype     => $itemtype,
253                 branchcode   => $self->branchcode,
254             }
255         );
256         my $calendar = Koha::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode );
257
258         $new_expiration_date = $calendar->days_forward( dt_from_string($self->waitingdate), $max_pickup_delay );
259     }
260
261     # If patron's requested expiration date is prior to the
262     # calculated one, we keep the patron's one.
263     if ( $self->patron_expiration_date ) {
264         my $requested_expiration = dt_from_string( $self->patron_expiration_date );
265
266         my $cmp =
267           $requested_expiration
268           ? DateTime->compare( $requested_expiration, $new_expiration_date )
269           : 0;
270
271         $new_expiration_date =
272           $cmp == -1 ? $requested_expiration : $new_expiration_date;
273     }
274
275     $values->{expirationdate} = $new_expiration_date->ymd;
276
277     $self->set($values)->store();
278
279     Koha::Plugins->call(
280         'after_hold_action',
281         {
282             action  => 'waiting',
283             payload => { hold => $self->get_from_storage }
284         }
285     );
286
287     return $self;
288 }
289
290 =head3 is_pickup_location_valid
291
292     if ($hold->is_pickup_location_valid({ library_id => $library->id }) ) {
293         ...
294     }
295
296 Returns a I<boolean> representing if the passed pickup location is valid for the hold.
297 It throws a I<Koha::Exceptions::_MissingParameter> if the library_id parameter is not
298 passed.
299
300 =cut
301
302 sub is_pickup_location_valid {
303     my ( $self, $params ) = @_;
304
305     Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
306         unless $params->{library_id};
307
308     my $pickup_locations;
309
310     if ( $self->itemnumber ) { # item-level
311         $pickup_locations = $self->item->pickup_locations({ patron => $self->patron });
312     }
313     else { # biblio-level
314         $pickup_locations = $self->biblio->pickup_locations({ patron => $self->patron });
315     }
316
317     return any { $_->branchcode eq $params->{library_id} } $pickup_locations->as_list;
318 }
319
320 =head3 set_pickup_location
321
322     $hold->set_pickup_location(
323         {
324             library_id => $library->id,
325           [ force   => 0|1 ]
326         }
327     );
328
329 Updates the hold pickup location. It throws a I<Koha::Exceptions::Hold::InvalidPickupLocation> if
330 the passed pickup location is not valid.
331
332 Note: It is up to the caller to verify if I<AllowHoldPolicyOverride> is set when setting the
333 B<force> parameter.
334
335 =cut
336
337 sub set_pickup_location {
338     my ( $self, $params ) = @_;
339
340     Koha::Exceptions::MissingParameter->throw('The library_id parameter is mandatory')
341         unless $params->{library_id};
342
343     if (
344         $params->{force}
345         || $self->is_pickup_location_valid(
346             { library_id => $params->{library_id} }
347         )
348       )
349     {
350         # all good, set the new pickup location
351         $self->branchcode( $params->{library_id} )->store;
352     }
353     else {
354         Koha::Exceptions::Hold::InvalidPickupLocation->throw;
355     }
356
357     return $self;
358 }
359
360 =head3 set_processing
361
362 $hold->set_processing;
363
364 Mark the hold as in processing.
365
366 =cut
367
368 sub set_processing {
369     my ( $self ) = @_;
370
371     $self->priority(0);
372     $self->found('P');
373     $self->store();
374
375     Koha::Plugins->call(
376         'after_hold_action',
377         {
378             action  => 'processing',
379             payload => { hold => $self->get_from_storage }
380         }
381     );
382
383     return $self;
384 }
385
386 =head3 is_found
387
388 Returns true if hold is waiting, in transit or in processing
389
390 =cut
391
392 sub is_found {
393     my ($self) = @_;
394
395     return 0 unless $self->found();
396     return 1 if $self->found() eq 'W';
397     return 1 if $self->found() eq 'T';
398     return 1 if $self->found() eq 'P';
399 }
400
401 =head3 is_waiting
402
403 Returns true if hold is a waiting hold
404
405 =cut
406
407 sub is_waiting {
408     my ($self) = @_;
409
410     my $found = $self->found;
411     return $found && $found eq 'W';
412 }
413
414 =head3 is_in_transit
415
416 Returns true if hold is a in_transit hold
417
418 =cut
419
420 sub is_in_transit {
421     my ($self) = @_;
422
423     return 0 unless $self->found();
424     return $self->found() eq 'T';
425 }
426
427 =head3 is_in_processing
428
429 Returns true if hold is a in_processing hold
430
431 =cut
432
433 sub is_in_processing {
434     my ($self) = @_;
435
436     return 0 unless $self->found();
437     return $self->found() eq 'P';
438 }
439
440 =head3 is_cancelable_from_opac
441
442 Returns true if hold is a cancelable hold
443
444 Holds may be only canceled if they are not found.
445
446 This is used from the OPAC.
447
448 =cut
449
450 sub is_cancelable_from_opac {
451     my ($self) = @_;
452
453     return 1 unless $self->is_found();
454     return 0; # if ->is_in_transit or if ->is_waiting or ->is_in_processing
455 }
456
457 =head3 cancellation_requestable_from_opac
458
459     if ( $hold->cancellation_requestable_from_opac ) { ... }
460
461 Returns a I<boolean> representing if a cancellation request can be placed on the hold
462 from the OPAC. It targets holds that cannot be cancelled from the OPAC (see the
463 B<is_cancelable_from_opac> method above), but for which circulation rules allow
464 requesting cancellation.
465
466 Throws a B<Koha::Exceptions::InvalidStatus> exception with the following I<invalid_status>
467 values:
468
469 =over 4
470
471 =item B<'hold_not_waiting'>: the hold is expected to be waiting and it is not.
472
473 =item B<'no_item_linked'>: the waiting hold doesn't have an item properly linked.
474
475 =back
476
477 =cut
478
479 sub cancellation_requestable_from_opac {
480     my ( $self ) = @_;
481
482     Koha::Exceptions::InvalidStatus->throw( invalid_status => 'hold_not_waiting' )
483       unless $self->is_waiting;
484
485     my $item = $self->item;
486
487     Koha::Exceptions::InvalidStatus->throw( invalid_status => 'no_item_linked' )
488       unless $item;
489
490     my $patron = $self->patron;
491
492     my $controlbranch = $patron->branchcode;
493
494     if ( C4::Context->preference('ReservesControlBranch') eq 'ItemHomeLibrary' ) {
495         $controlbranch = $item->homebranch;
496     }
497
498     return Koha::CirculationRules->get_effective_rule_value(
499         {
500             categorycode => $patron->categorycode,
501             itemtype     => $item->itype,
502             branchcode   => $controlbranch,
503             rule_name    => 'waiting_hold_cancellation',
504         }
505     ) ? 1 : 0;
506 }
507
508 =head3 is_at_destination
509
510 Returns true if hold is waiting
511 and the hold's pickup branch matches
512 the hold item's holding branch
513
514 =cut
515
516 sub is_at_destination {
517     my ($self) = @_;
518
519     return $self->is_waiting() && ( $self->branchcode() eq $self->item()->holdingbranch() );
520 }
521
522 =head3 biblio
523
524 Returns the related Koha::Biblio object for this hold
525
526 =cut
527
528 sub biblio {
529     my ($self) = @_;
530     my $rs = $self->_result->biblionumber;
531     return Koha::Biblio->_new_from_dbic($rs);
532 }
533
534 =head3 patron
535
536 Returns the related Koha::Patron object for this hold
537
538 =cut
539
540 sub patron {
541     my ($self) = @_;
542     my $rs = $self->_result->patron;
543     return Koha::Patron->_new_from_dbic($rs);
544 }
545
546 =head3 item
547
548 Returns the related Koha::Item object for this Hold
549
550 =cut
551
552 sub item {
553     my ($self) = @_;
554     my $rs = $self->_result->itemnumber;
555     return unless $rs;
556     return Koha::Item->_new_from_dbic($rs);
557 }
558
559 =head3 item_group
560
561 Returns the related Koha::Biblio::ItemGroup object for this Hold
562
563 =cut
564
565 sub item_group {
566     my ($self) = @_;
567     my $rs = $self->_result->item_group;
568     return unless $rs;
569     return Koha::Biblio::ItemGroup->_new_from_dbic($rs);
570 }
571
572 =head3 branch
573
574 Returns the related Koha::Library object for this Hold
575
576 =cut
577
578 sub branch {
579     my ($self) = @_;
580     my $rs = $self->_result->branchcode;
581     return Koha::Library->_new_from_dbic($rs);
582 }
583
584 =head3 desk
585
586 Returns the related Koha::Desk object for this Hold
587
588 =cut
589
590 sub desk {
591     my $self = shift;
592     my $desk_rs = $self->_result->desk;
593     return unless $desk_rs;
594     return Koha::Desk->_new_from_dbic($desk_rs);
595 }
596
597 =head3 borrower
598
599 Returns the related Koha::Patron object for this Hold
600
601 =cut
602
603 # FIXME Should be renamed with ->patron
604 sub borrower {
605     my ($self) = @_;
606     my $rs = $self->_result->borrowernumber;
607     return Koha::Patron->_new_from_dbic($rs);
608 }
609
610 =head3 is_suspended
611
612 my $bool = $hold->is_suspended();
613
614 =cut
615
616 sub is_suspended {
617     my ( $self ) = @_;
618
619     return $self->suspend();
620 }
621
622 =head3 add_cancellation_request
623
624     my $cancellation_request = $hold->add_cancellation_request({ [ creation_date => $creation_date ] });
625
626 Adds a cancellation request to the hold. Returns the generated
627 I<Koha::Hold::CancellationRequest> object.
628
629 =cut
630
631 sub add_cancellation_request {
632     my ( $self, $params ) = @_;
633
634     my $request = Koha::Hold::CancellationRequest->new(
635         {   hold_id      => $self->id,
636             ( $params->{creation_date} ? ( creation_date => $params->{creation_date} ) : () ),
637         }
638     )->store;
639
640     $request->discard_changes;
641
642     return $request;
643 }
644
645 =head3 cancellation_requests
646
647     my $cancellation_requests = $hold->cancellation_requests;
648
649 Returns related a I<Koha::Hold::CancellationRequests> resultset.
650
651 =cut
652
653 sub cancellation_requests {
654     my ($self) = @_;
655
656     return Koha::Hold::CancellationRequests->search( { hold_id => $self->id } );
657 }
658
659 =head3 cancellation_requested
660
661     if ( $hold->cancellation_requested ) { ... }
662
663 Returns true if a cancellation request has been placed for the hold.
664
665 =cut
666
667 sub cancellation_requested {
668     my ($self) = @_;
669
670     return Koha::Hold::CancellationRequests->search( { hold_id => $self->id } )->count > 0;
671 }
672
673 =head3 cancel
674
675 my $cancel_hold = $hold->cancel(
676     {
677         [ charge_cancel_fee   => 1||0, ]
678         [ cancellation_reason => $cancellation_reason, ]
679         [ skip_holds_queue    => 1||0 ]
680     }
681 );
682
683 Cancel a hold:
684 - The hold will be moved to the old_reserves table with a priority=0
685 - The priority of other holds will be updated
686 - The patron will be charge (see ExpireReservesMaxPickUpDelayCharge) if the charge_cancel_fee parameter is set
687 - The canceled hold will have the cancellation reason added to old_reserves.cancellation_reason if one is passed in
688 - a CANCEL HOLDS log will be done if the pref HoldsLog is on
689
690 =cut
691
692 sub cancel {
693     my ( $self, $params ) = @_;
694
695     my $autofill_next = $params->{autofill} && $self->itemnumber && $self->found && $self->found eq 'W';
696
697     $self->_result->result_source->schema->txn_do(
698         sub {
699             my $patron = $self->patron;
700
701             $self->cancellationdate( dt_from_string->strftime( '%Y-%m-%d %H:%M:%S' ) );
702             $self->priority(0);
703             $self->cancellation_reason( $params->{cancellation_reason} );
704             $self->store();
705
706             if ( $params->{cancellation_reason} ) {
707                 my $letter = C4::Letters::GetPreparedLetter(
708                     module                 => 'reserves',
709                     letter_code            => 'HOLD_CANCELLATION',
710                     message_transport_type => 'email',
711                     branchcode             => $self->borrower->branchcode,
712                     lang                   => $self->borrower->lang,
713                     tables => {
714                         branches    => $self->borrower->branchcode,
715                         borrowers   => $self->borrowernumber,
716                         items       => $self->itemnumber,
717                         biblio      => $self->biblionumber,
718                         biblioitems => $self->biblionumber,
719                         reserves    => $self->unblessed,
720                     }
721                 );
722
723                 if ($letter) {
724                     C4::Letters::EnqueueLetter(
725                         {
726                             letter                   => $letter,
727                             borrowernumber         => $self->borrowernumber,
728                             message_transport_type => 'email',
729                         }
730                     );
731                 }
732             }
733
734             my $old_me = $self->_move_to_old;
735
736             Koha::Plugins->call(
737                 'after_hold_action',
738                 {
739                     action  => 'cancel',
740                     payload => { hold => $old_me->get_from_storage }
741                 }
742             );
743
744             # anonymize if required
745             $old_me->anonymize
746                 if $patron->privacy == 2;
747
748             $self->SUPER::delete(); # Do not add a DELETE log
749             # now fix the priority on the others....
750             C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber });
751
752             # and, if desired, charge a cancel fee
753             my $charge = C4::Context->preference("ExpireReservesMaxPickUpDelayCharge");
754             if ( $charge && $params->{'charge_cancel_fee'} ) {
755                 my $account =
756                   Koha::Account->new( { patron_id => $self->borrowernumber } );
757                 $account->add_debit(
758                     {
759                         amount     => $charge,
760                         user_id    => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
761                         interface  => C4::Context->interface,
762                         library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
763                         type       => 'RESERVE_EXPIRED',
764                         item_id    => $self->itemnumber
765                     }
766                 );
767             }
768
769             C4::Log::logaction( 'HOLDS', 'CANCEL', $self->reserve_id, $self )
770                 if C4::Context->preference('HoldsLog');
771
772             Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
773                 {
774                     biblio_ids => [ $old_me->biblionumber ]
775                 }
776             ) unless $params->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue');
777         }
778     );
779
780     if ($autofill_next) {
781         my ( undef, $next_hold ) = C4::Reserves::CheckReserves( $self->item );
782         if ($next_hold) {
783             my $is_transfer = $self->branchcode ne $next_hold->{branchcode};
784
785             C4::Reserves::ModReserveAffect( $self->itemnumber, $self->borrowernumber, $is_transfer, $next_hold->{reserve_id}, $self->desk_id, $autofill_next );
786             C4::Items::ModItemTransfer( $self->itemnumber, $self->branchcode, $next_hold->{branchcode}, "Reserve" ) if $is_transfer;
787         }
788     }
789
790     return $self;
791 }
792
793 =head3 fill
794
795     $hold->fill({ [ item_id => $item->id ] });
796
797 This method marks the hold as filled. It effectively moves it to old_reserves.
798 The optional I<item_id> parameter is used to set the information about the
799 item that filled the hold.
800
801 =cut
802
803 sub fill {
804     my ( $self, $params ) = @_;
805     $self->_result->result_source->schema->txn_do(
806         sub {
807             my $patron = $self->patron;
808
809             $self->set(
810                 {
811                     found    => 'F',
812                     priority => 0,
813                     $params->{item_id} ? ( itemnumber => $params->{item_id} ) : (),
814                 }
815             );
816
817             my $old_me = $self->_move_to_old;
818
819             Koha::Plugins->call(
820                 'after_hold_action',
821                 {
822                     action  => 'fill',
823                     payload => { hold => $old_me->get_from_storage }
824                 }
825             );
826
827             # anonymize if required
828             $old_me->anonymize
829                 if $patron->privacy == 2;
830
831             $self->SUPER::delete(); # Do not add a DELETE log
832
833             # now fix the priority on the others....
834             C4::Reserves::_FixPriority({ biblionumber => $self->biblionumber });
835
836             if ( C4::Context->preference('HoldFeeMode') eq 'any_time_is_collected' ) {
837                 my $fee = $patron->category->reservefee // 0;
838                 if ( $fee > 0 ) {
839                     $patron->account->add_debit(
840                         {
841                             amount       => $fee,
842                             description  => $self->biblio->title,
843                             user_id      => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
844                             library_id   => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
845                             interface    => C4::Context->interface,
846                             type         => 'RESERVE',
847                             item_id      => $self->itemnumber
848                         }
849                     );
850                 }
851             }
852
853             C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self )
854                 if C4::Context->preference('HoldsLog');
855
856             Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
857                 {
858                     biblio_ids => [ $old_me->biblionumber ]
859                 }
860             ) if C4::Context->preference('RealTimeHoldsQueue');
861         }
862     );
863     return $self;
864 }
865
866 =head3 sub change_type
867
868     $hold->change_type                # to record level
869     $hold->change_type( $itemnumber ) # to item level
870
871 Changes hold type between record and item level holds, only if record has
872 exactly one hold for a patron. This is because Koha expects all holds for
873 a patron on a record to be alike.
874
875 =cut
876
877 sub change_type {
878     my ( $self, $itemnumber ) = @_;
879
880     my $record_holds_per_patron = Koha::Holds->search(
881         {
882             borrowernumber => $self->borrowernumber,
883             biblionumber   => $self->biblionumber,
884         }
885     );
886
887     if ( $itemnumber && $self->itemnumber ) {
888         $self->itemnumber($itemnumber)->store;
889         return $self;
890     }
891
892     if ( $record_holds_per_patron->count == 1 ) {
893         $self->set(
894             {
895                 itemnumber      => $itemnumber ? $itemnumber : undef,
896                 item_level_hold => $itemnumber ? 1           : 0,
897             }
898         )->store;
899     } else {
900         Koha::Exceptions::Hold::CannotChangeHoldType->throw();
901     }
902
903     return $self;
904 }
905
906 =head3 store
907
908 Override base store method to set default
909 expirationdate for holds.
910
911 =cut
912
913 sub store {
914     my ($self) = @_;
915
916     Koha::Exceptions::Hold::MissingPickupLocation->throw() unless $self->branchcode;
917
918     if ( !$self->in_storage ) {
919         if ( ! $self->expirationdate && $self->patron_expiration_date ) {
920             $self->expirationdate($self->patron_expiration_date);
921         }
922
923         if (
924             C4::Context->preference('DefaultHoldExpirationdate')
925                 && !$self->expirationdate
926           )
927         {
928             $self->_set_default_expirationdate;
929         }
930     }
931     else {
932
933         my %updated_columns = $self->_result->get_dirty_columns;
934         return $self->SUPER::store unless %updated_columns;
935
936         if ( exists $updated_columns{reservedate} ) {
937             if (
938                 C4::Context->preference('DefaultHoldExpirationdate')
939                 && ! exists $updated_columns{expirationdate}
940               )
941             {
942                 $self->_set_default_expirationdate;
943             }
944         }
945     }
946
947     $self = $self->SUPER::store;
948 }
949
950 sub _set_default_expirationdate {
951     my $self = shift;
952
953     my $period = C4::Context->preference('DefaultHoldExpirationdatePeriod') || 0;
954     my $timeunit =
955       C4::Context->preference('DefaultHoldExpirationdateUnitOfTime') || 'days';
956
957     $self->expirationdate(
958         dt_from_string( $self->reservedate )->add( $timeunit => $period ) );
959 }
960
961 =head3 _move_to_old
962
963 my $is_moved = $hold->_move_to_old;
964
965 Move a hold to the old_reserve table following the same pattern as Koha::Patron->move_to_deleted
966
967 =cut
968
969 sub _move_to_old {
970     my ($self) = @_;
971     my $hold_infos = $self->unblessed;
972     return Koha::Old::Hold->new( $hold_infos )->store;
973 }
974
975 =head3 to_api_mapping
976
977 This method returns the mapping for representing a Koha::Hold object
978 on the API.
979
980 =cut
981
982 sub to_api_mapping {
983     return {
984         reserve_id       => 'hold_id',
985         borrowernumber   => 'patron_id',
986         reservedate      => 'hold_date',
987         biblionumber     => 'biblio_id',
988         branchcode       => 'pickup_library_id',
989         notificationdate => undef,
990         reminderdate     => undef,
991         cancellationdate => 'cancellation_date',
992         reservenotes     => 'notes',
993         found            => 'status',
994         itemnumber       => 'item_id',
995         waitingdate      => 'waiting_date',
996         expirationdate   => 'expiration_date',
997         patron_expiration_date => undef,
998         lowestPriority   => 'lowest_priority',
999         suspend          => 'suspended',
1000         suspend_until    => 'suspended_until',
1001         itemtype         => 'item_type',
1002         item_level_hold  => 'item_level',
1003     };
1004 }
1005
1006 =head3 can_update_pickup_location_opac
1007
1008     my $can_update_pickup_location_opac = $hold->can_update_pickup_location_opac;
1009
1010 Returns if a hold can change pickup location from opac
1011
1012 =cut
1013
1014 sub can_update_pickup_location_opac {
1015     my ($self) = @_;
1016
1017     my @statuses = split /,/, C4::Context->preference("OPACAllowUserToChangeBranch");
1018     foreach my $status ( @statuses ){
1019         return 1 if ($status eq 'pending' && !$self->is_found && !$self->is_suspended );
1020         return 1 if ($status eq 'intransit' && $self->is_in_transit);
1021         return 1 if ($status eq 'suspended' && $self->is_suspended);
1022     }
1023     return 0;
1024 }
1025
1026 =head2 Internal methods
1027
1028 =head3 _type
1029
1030 =cut
1031
1032 sub _type {
1033     return 'Reserve';
1034 }
1035
1036 =head1 AUTHORS
1037
1038 Kyle M Hall <kyle@bywatersolutions.com>
1039 Jonathan Druart <jonathan.druart@bugs.koha-community.org>
1040 Martin Renvoize <martin.renvoize@ptfs-europe.com>
1041
1042 =cut
1043
1044 1;