Bug 33028: Make exception less generic
[koha.git] / Koha / CirculationRules.pm
1 package Koha::CirculationRules;
2
3 # Copyright ByWater Solutions 2017
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21 use Carp qw( croak );
22
23 use Koha::Exceptions;
24 use Koha::Exceptions::CirculationRule;
25 use Koha::CirculationRule;
26 use Koha::Caches;
27 use Koha::Cache::Memory::Lite;
28 use Koha::Number::Price;
29
30 use base qw(Koha::Objects);
31
32 use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';
33
34 =head1 NAME
35
36 Koha::CirculationRules - Koha CirculationRule Object set class
37
38 =head1 API
39
40 =head2 Class Methods
41
42 =cut
43
44 =head3 rule_kinds
45
46 This structure describes the possible rules that may be set, and what scopes they can be set at.
47
48 Any attempt to set a rule with a nonsensical scope (for instance, setting the C<patron_maxissueqty> for a branchcode and itemtype), is an error.
49
50 =cut
51
52 our $RULE_KINDS = {
53     lostreturn => {
54         scope => [ 'branchcode' ],
55     },
56     processingreturn => {
57         scope => [ 'branchcode' ],
58     },
59     patron_maxissueqty => {
60         scope => [ 'branchcode', 'categorycode' ],
61     },
62     patron_maxonsiteissueqty => {
63         scope => [ 'branchcode', 'categorycode' ],
64     },
65     max_holds => {
66         scope => [ 'branchcode', 'categorycode' ],
67     },
68
69     holdallowed => {
70         scope => [ 'branchcode', 'itemtype' ],
71         can_be_blank => 0,
72     },
73     hold_fulfillment_policy => {
74         scope => [ 'branchcode', 'itemtype' ],
75         can_be_blank => 0,
76     },
77     returnbranch => {
78         scope => [ 'branchcode', 'itemtype' ],
79         can_be_blank => 0,
80     },
81
82     article_requests => {
83         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
84     },
85     article_request_fee => {
86         scope => [ 'branchcode', 'categorycode' ],
87         is_monetary => 1,
88     },
89     open_article_requests_limit => {
90         scope => [ 'branchcode', 'categorycode' ],
91     },
92
93     auto_renew => {
94         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
95     },
96     cap_fine_to_replacement_price => {
97         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
98     },
99     chargeperiod => {
100         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
101     },
102     chargeperiod_charge_at => {
103         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
104     },
105     fine => {
106         scope       => [ 'branchcode', 'categorycode', 'itemtype' ],
107         is_monetary => 1,
108     },
109     finedays => {
110         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
111     },
112     firstremind => {
113         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
114     },
115     hardduedate => {
116         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117     },
118     hardduedatecompare => {
119         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
120     },
121     waiting_hold_cancellation => {
122         scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
123         can_be_blank => 0,
124     },
125     holds_per_day => {
126         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127     },
128     holds_per_record => {
129         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130     },
131     issuelength => {
132         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133     },
134     daysmode => {
135         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136     },
137     lengthunit => {
138         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139     },
140     maxissueqty => {
141         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142     },
143     maxonsiteissueqty => {
144         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145     },
146     maxsuspensiondays => {
147         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148     },
149     no_auto_renewal_after => {
150         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151     },
152     no_auto_renewal_after_hard_limit => {
153         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154     },
155     norenewalbefore => {
156         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157     },
158     onshelfholds => {
159         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160     },
161     opacitemholds => {
162         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163     },
164     overduefinescap => {
165         scope       => [ 'branchcode', 'categorycode', 'itemtype' ],
166         is_monetary => 1,
167     },
168     renewalperiod => {
169         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
170     },
171     renewalsallowed => {
172         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
173     },
174     unseen_renewals_allowed => {
175         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
176     },
177     rentaldiscount => {
178         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
179         can_be_blank => 0,
180     },
181     reservesallowed => {
182         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
183     },
184     suspension_chargeperiod => {
185         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
186     },
187     note => { # This is not really a rule. Maybe we will want to separate this later.
188         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
189     },
190     decreaseloanholds => {
191         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
192     },
193     recalls_allowed => {
194         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
195     },
196     recalls_per_record => {
197         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
198     },
199     on_shelf_recalls => {
200         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
201     },
202     recall_due_date_interval => {
203         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
204     },
205     recall_overdue_fine => {
206         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
207         is_monetary => 1,
208     },
209     recall_shelf_time => {
210         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
211     },
212     # Not included (deprecated?):
213     #   * accountsent
214     #   * reservecharge
215     #   * restrictedtype
216 };
217
218 sub rule_kinds {
219     return $RULE_KINDS;
220 }
221
222 =head3 get_effective_rule
223
224   my $effective_rule = Koha::CirculationRules->get_effective_rule(
225     {
226         rule_name    => $name,
227         categorycode => $categorycode,
228         itemtype     => $itemtype,
229         branchcode   => $branchcode
230     }
231   );
232
233 Return the effective rule object for the rule associated with the criteria passed.
234
235
236 =cut
237
238 sub get_effective_rule {
239     my ( $self, $params ) = @_;
240
241     $params->{categorycode} //= undef;
242     $params->{branchcode}   //= undef;
243     $params->{itemtype}     //= undef;
244
245     my $rule_name    = $params->{rule_name};
246     my $categorycode = $params->{categorycode};
247     my $itemtype     = $params->{itemtype};
248     my $branchcode   = $params->{branchcode};
249
250     Koha::Exceptions::MissingParameter->throw(
251         "Required parameter 'rule_name' missing")
252       unless $rule_name;
253
254     for my $v ( $branchcode, $categorycode, $itemtype ) {
255         $v = undef if $v and $v eq '*';
256     }
257
258     my $order_by = $params->{order_by}
259       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
260
261     my $search_params;
262     $search_params->{rule_name} = $rule_name;
263
264     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
265     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
266     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
267
268     my $rule = $self->search(
269         $search_params,
270         {
271             order_by => $order_by,
272             rows => 1,
273         }
274     )->single;
275
276     return $rule;
277 }
278
279 =head3 get_effective_rule_value
280
281   my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
282     {
283         rule_name    => $name,
284         categorycode => $categorycode,
285         itemtype     => $itemtype,
286         branchcode   => $branchcode
287     }
288   );
289
290 Return the effective value for the rule associated with the criteria passed.
291
292 This is a cached method so should be used in preference to get_effective_rule where possible
293 to aid performance.
294
295 =cut
296
297 sub get_effective_rule_value {
298     my ( $self, $params ) = @_;
299
300     my $rule_name    = $params->{rule_name};
301     my $categorycode = $params->{categorycode};
302     my $itemtype     = $params->{itemtype};
303     my $branchcode   = $params->{branchcode};
304
305     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
306     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
307       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
308
309     my $cached       = $memory_cache->get_from_cache($cache_key);
310     return $cached if $cached;
311
312     my $rule = $self->get_effective_rule($params);
313
314     my $value= $rule ? $rule->rule_value : undef;
315     $memory_cache->set_in_cache( $cache_key, $value );
316     return $value;
317 }
318
319 =head3 get_effective_rules
320
321 =cut
322
323 sub get_effective_rules {
324     my ( $self, $params ) = @_;
325
326     my $rules        = $params->{rules};
327     my $categorycode = $params->{categorycode};
328     my $itemtype     = $params->{itemtype};
329     my $branchcode   = $params->{branchcode};
330
331     my $r;
332     foreach my $rule (@$rules) {
333         my $effective_rule = $self->get_effective_rule_value(
334             {
335                 rule_name    => $rule,
336                 categorycode => $categorycode,
337                 itemtype     => $itemtype,
338                 branchcode   => $branchcode,
339             }
340         );
341
342         $r->{$rule} = $effective_rule if defined $effective_rule;
343     }
344
345     return $r;
346 }
347
348 =head3 set_rule
349
350 =cut
351
352 sub set_rule {
353     my ( $self, $params ) = @_;
354
355     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
356         Koha::Exceptions::MissingParameter->throw(
357             "Required parameter '$mandatory_parameter' missing")
358           unless exists $params->{$mandatory_parameter};
359     }
360
361     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
362     Koha::Exceptions::MissingParameter->throw(
363         "set_rule given unknown rule '$params->{rule_name}'!")
364         unless defined $kind_info;
365
366     # Enforce scope; a rule should be set for its defined scope, no more, no less.
367     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
368         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
369             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
370                 unless exists $params->{$scope_level};
371         } else {
372             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
373                 if exists $params->{$scope_level};
374         }
375     }
376
377     my $branchcode   = $params->{branchcode};
378     my $categorycode = $params->{categorycode};
379     my $itemtype     = $params->{itemtype};
380     my $rule_name    = $params->{rule_name};
381     my $rule_value   = $params->{rule_value};
382     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
383     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
384     my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
385     Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
386         if ( $is_monetary && defined($rule_value) && $rule_value !~ /^\d+(\.\d{2})?$/ );
387
388     for my $v ( $branchcode, $categorycode, $itemtype ) {
389         $v = undef if $v and $v eq '*';
390     }
391     my $rule = $self->search(
392         {
393             rule_name    => $rule_name,
394             branchcode   => $branchcode,
395             categorycode => $categorycode,
396             itemtype     => $itemtype,
397         }
398     )->next();
399
400     if ($rule) {
401         if ( defined $rule_value ) {
402             $rule->rule_value($rule_value);
403             $rule->update();
404         }
405         else {
406             $rule->delete();
407         }
408     }
409     else {
410         if ( defined $rule_value ) {
411             $rule = Koha::CirculationRule->new(
412                 {
413                     branchcode   => $branchcode,
414                     categorycode => $categorycode,
415                     itemtype     => $itemtype,
416                     rule_name    => $rule_name,
417                     rule_value   => $rule_value,
418                 }
419             );
420             $rule->store();
421         }
422     }
423
424     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
425     for my $k ( $memory_cache->all_keys ) {
426         $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
427     }
428
429     return $rule;
430 }
431
432 =head3 set_rules
433
434 =cut
435
436 sub set_rules {
437     my ( $self, $params ) = @_;
438
439     my %set_params;
440     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
441     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
442     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
443     my $rules        = $params->{rules};
444
445     my $rule_objects = [];
446     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
447         my $rule_object = Koha::CirculationRules->set_rule(
448             {
449                 %set_params,
450                 rule_name    => $rule_name,
451                 rule_value   => $rule_value,
452             }
453         );
454         push( @$rule_objects, $rule_object );
455     }
456
457     return $rule_objects;
458 }
459
460 =head3 delete
461
462 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
463
464 =cut
465
466 sub delete {
467     my ( $self ) = @_;
468
469     while ( my $rule = $self->next ){
470         $rule->delete;
471     }
472 }
473
474 =head3 clone
475
476 Clone a set of circulation rules to another branch
477
478 =cut
479
480 sub clone {
481     my ( $self, $to_branch ) = @_;
482
483     while ( my $rule = $self->next ){
484         $rule->clone($to_branch);
485     }
486 }
487
488 =head2 get_return_branch_policy
489
490   my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
491
492 Returns the branch to use for returning the item based on the
493 item type, and a branch selected via CircControlReturnsBranch.
494
495 The return value is the branch to which to return the item. Possible values:
496   noreturn: do not return, let item remain where checked in (floating collections)
497   homebranch: return to item's home branch
498   holdingbranch: return to issuer branch
499
500 This searches branchitemrules in the following order:
501   * Same branchcode and itemtype
502   * Same branchcode, itemtype '*'
503   * branchcode '*', same itemtype
504   * branchcode '*' and itemtype '*'
505
506 =cut
507
508 sub get_return_branch_policy {
509     my ( $self, $item ) = @_;
510
511     my $pref = C4::Context->preference('CircControlReturnsBranch');
512
513     my $branchcode =
514         $pref eq 'ItemHomeLibrary'     ? $item->homebranch
515       : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
516       : $pref eq 'CheckInLibrary'      ? C4::Context->userenv
517           ? C4::Context->userenv->{branch}
518           : $item->homebranch
519       : $item->homebranch;
520
521     my $itemtype = $item->effective_itemtype;
522
523     my $rule = Koha::CirculationRules->get_effective_rule(
524         {
525             rule_name  => 'returnbranch',
526             itemtype   => $itemtype,
527             branchcode => $branchcode,
528         }
529     );
530
531     return $rule ? $rule->rule_value : 'homebranch';
532 }
533
534
535 =head3 get_opacitemholds_policy
536
537 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
538
539 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
540 and the "Item level holds" (opacitemholds).
541 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
542
543 =cut
544
545 sub get_opacitemholds_policy {
546     my ( $class, $params ) = @_;
547
548     my $item   = $params->{item};
549     my $patron = $params->{patron};
550
551     return unless $item or $patron;
552
553     my $rule = Koha::CirculationRules->get_effective_rule(
554         {
555             categorycode => $patron->categorycode,
556             itemtype     => $item->effective_itemtype,
557             branchcode   => $item->homebranch,
558             rule_name    => 'opacitemholds',
559         }
560     );
561
562     return $rule ? $rule->rule_value : undef;
563 }
564
565 =head3 get_onshelfholds_policy
566
567     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
568
569 =cut
570
571 sub get_onshelfholds_policy {
572     my ( $class, $params ) = @_;
573     my $item = $params->{item};
574     my $itemtype = $item->effective_itemtype;
575     my $patron = $params->{patron};
576     my $rule = Koha::CirculationRules->get_effective_rule(
577         {
578             categorycode => ( $patron ? $patron->categorycode : undef ),
579             itemtype     => $itemtype,
580             branchcode   => $item->holdingbranch,
581             rule_name    => 'onshelfholds',
582         }
583     );
584     return $rule ? $rule->rule_value : 0;
585 }
586
587 =head3 get_lostreturn_policy
588
589   my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
590
591 lostreturn return values are:
592
593 =over 2
594
595 =item '0' - Do not refund
596
597 =item 'refund' - Refund the lost item charge
598
599 =item 'restore' - Refund the lost item charge and restore the original overdue fine
600
601 =item 'charge' - Refund the lost item charge and charge a new overdue fine
602
603 =back
604
605 processing return return values are:
606
607 =over 2
608
609 =item '0' - Do not refund
610
611 =item 'refund' - Refund the lost item processing charge
612
613 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
614
615 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
616
617 =back
618
619
620 =cut
621
622 sub get_lostreturn_policy {
623     my ( $class, $params ) = @_;
624
625     my $item   = $params->{item};
626
627     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
628     my $behaviour_mapping = {
629         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
630         ItemHomeBranch    => $item->homebranch,
631         ItemHoldingBranch => $item->holdingbranch
632     };
633
634     my $branch = $behaviour_mapping->{ $behaviour };
635
636     my $rules = Koha::CirculationRules->get_effective_rules(
637         {
638             branchcode => $branch,
639             rules  => ['lostreturn','processingreturn']
640         }
641     );
642
643     $rules->{lostreturn} //= 'refund';
644     $rules->{processingreturn} //= 'refund';
645     return $rules;
646 }
647
648 =head3 article_requestable_rules
649
650     Return rules that allow article requests, optionally filtered by
651     patron categorycode.
652
653     Use with care; see guess_article_requestable_itemtypes.
654
655 =cut
656
657 sub article_requestable_rules {
658     my ( $class, $params ) = @_;
659     my $category = $params->{categorycode};
660
661     return if !C4::Context->preference('ArticleRequests');
662     return $class->search({
663         $category ? ( categorycode => [ $category, undef ] ) : (),
664         rule_name => 'article_requests',
665         rule_value => { '!=' => 'no' },
666     });
667 }
668
669 =head3 guess_article_requestable_itemtypes
670
671     Return item types in a hashref that are likely possible to be
672     'article requested'. Constructed by an intelligent guess in the
673     issuing rules (see article_requestable_rules).
674
675     Note: pref ArticleRequestsLinkControl overrides the algorithm.
676
677     Optional parameters: categorycode.
678
679     Note: the routine is used in opac-search to obtain a reasonable
680     estimate within performance borders (not looking at all items but
681     just using default itemtype). Also we are not looking at the
682     branchcode here, since home or holding branch of the item is
683     leading and branch may be unknown too (anonymous opac session).
684
685 =cut
686
687 sub guess_article_requestable_itemtypes {
688     my ( $class, $params ) = @_;
689     my $category = $params->{categorycode};
690     return {} if !C4::Context->preference('ArticleRequests');
691     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
692
693     my $cache = Koha::Caches->get_instance;
694     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
695     my $key = $category || '*';
696     return $last_article_requestable_guesses->{$key}
697         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
698
699     my $res = {};
700     my $rules = $class->article_requestable_rules({
701         $category ? ( categorycode => $category ) : (),
702     });
703     return $res if !$rules;
704     foreach my $rule ( $rules->as_list ) {
705         $res->{ $rule->itemtype // '*' } = 1;
706     }
707     $last_article_requestable_guesses->{$key} = $res;
708     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
709     return $res;
710 }
711
712 =head3 get_effective_daysmode
713
714 Return the value for daysmode defined in the circulation rules.
715 If not defined (or empty string), the value of the system preference useDaysMode is returned
716
717 =cut
718
719 sub get_effective_daysmode {
720     my ( $class, $params ) = @_;
721
722     my $categorycode     = $params->{categorycode};
723     my $itemtype         = $params->{itemtype};
724     my $branchcode       = $params->{branchcode};
725
726     my $daysmode_rule = $class->get_effective_rule(
727         {
728             categorycode => $categorycode,
729             itemtype     => $itemtype,
730             branchcode   => $branchcode,
731             rule_name    => 'daysmode',
732         }
733     );
734
735     return ( defined($daysmode_rule)
736           and $daysmode_rule->rule_value ne '' )
737       ? $daysmode_rule->rule_value
738       : C4::Context->preference('useDaysMode');
739
740 }
741
742
743 =head3 type
744
745 =cut
746
747 sub _type {
748     return 'CirculationRule';
749 }
750
751 =head3 object_class
752
753 =cut
754
755 sub object_class {
756     return 'Koha::CirculationRule';
757 }
758
759 1;