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