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