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