Bug 29623: Cache circulation rules
[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     holds_per_day => {
116         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117     },
118     holds_per_record => {
119         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
120     },
121     issuelength => {
122         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
123     },
124     daysmode => {
125         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
126     },
127     lengthunit => {
128         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
129     },
130     maxissueqty => {
131         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
132     },
133     maxonsiteissueqty => {
134         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
135     },
136     maxsuspensiondays => {
137         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
138     },
139     no_auto_renewal_after => {
140         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
141     },
142     no_auto_renewal_after_hard_limit => {
143         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
144     },
145     norenewalbefore => {
146         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
147     },
148     onshelfholds => {
149         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
150     },
151     opacitemholds => {
152         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
153     },
154     overduefinescap => {
155         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
156     },
157     renewalperiod => {
158         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
159     },
160     renewalsallowed => {
161         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
162     },
163     unseen_renewals_allowed => {
164         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
165     },
166     rentaldiscount => {
167         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
168         can_be_blank => 0,
169     },
170     reservesallowed => {
171         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
172     },
173     suspension_chargeperiod => {
174         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
175     },
176     note => { # This is not really a rule. Maybe we will want to separate this later.
177         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
178     },
179     decreaseloanholds => {
180         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
181     },
182     recalls_allowed => {
183         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
184     },
185     recalls_per_record => {
186         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
187     },
188     on_shelf_recalls => {
189         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
190     },
191     recall_due_date_interval => {
192         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
193     },
194     recall_overdue_fine => {
195         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
196     },
197     recall_shelf_time => {
198         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
199     },
200     # Not included (deprecated?):
201     #   * accountsent
202     #   * reservecharge
203     #   * restrictedtype
204 };
205
206 sub rule_kinds {
207     return $RULE_KINDS;
208 }
209
210 =head3 get_effective_rule
211
212 =cut
213
214 sub get_effective_rule {
215     my ( $self, $params ) = @_;
216
217     $params->{categorycode} //= undef;
218     $params->{branchcode}   //= undef;
219     $params->{itemtype}     //= undef;
220
221     my $rule_name    = $params->{rule_name};
222     my $categorycode = $params->{categorycode};
223     my $itemtype     = $params->{itemtype};
224     my $branchcode   = $params->{branchcode};
225
226     Koha::Exceptions::MissingParameter->throw(
227         "Required parameter 'rule_name' missing")
228       unless $rule_name;
229
230     for my $v ( $branchcode, $categorycode, $itemtype ) {
231         $v = undef if $v and $v eq '*';
232     }
233
234     my $order_by = $params->{order_by}
235       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
236
237     my $search_params;
238     $search_params->{rule_name} = $rule_name;
239
240     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
241     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
242     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
243
244     my $rule = $self->search(
245         $search_params,
246         {
247             order_by => $order_by,
248             rows => 1,
249         }
250     )->single;
251
252     return $rule;
253 }
254
255 sub get_effective_rule_value {
256     my ( $self, $params ) = @_;
257
258     my $rule_name    = $params->{rule_name};
259     my $categorycode = $params->{categorycode};
260     my $itemtype     = $params->{itemtype};
261     my $branchcode   = $params->{branchcode};
262
263     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
264     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
265       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
266
267     my $cached       = $memory_cache->get_from_cache($cache_key);
268     return $cached if $cached;
269
270     my $rule = $self->get_effective_rule($params);
271
272     my $value= $rule ? $rule->rule_value : undef;
273     $memory_cache->set_in_cache( $cache_key, $value );
274     return $value;
275 }
276
277 =head3 get_effective_rules
278
279 =cut
280
281 sub get_effective_rules {
282     my ( $self, $params ) = @_;
283
284     my $rules        = $params->{rules};
285     my $categorycode = $params->{categorycode};
286     my $itemtype     = $params->{itemtype};
287     my $branchcode   = $params->{branchcode};
288
289     my $r;
290     foreach my $rule (@$rules) {
291         my $effective_rule = $self->get_effective_rule_value(
292             {
293                 rule_name    => $rule,
294                 categorycode => $categorycode,
295                 itemtype     => $itemtype,
296                 branchcode   => $branchcode,
297             }
298         );
299
300         $r->{$rule} = $effective_rule if defined $effective_rule;
301     }
302
303     return $r;
304 }
305
306 =head3 set_rule
307
308 =cut
309
310 sub set_rule {
311     my ( $self, $params ) = @_;
312
313     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
314         Koha::Exceptions::MissingParameter->throw(
315             "Required parameter '$mandatory_parameter' missing")
316           unless exists $params->{$mandatory_parameter};
317     }
318
319     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
320     Koha::Exceptions::MissingParameter->throw(
321         "set_rule given unknown rule '$params->{rule_name}'!")
322         unless defined $kind_info;
323
324     # Enforce scope; a rule should be set for its defined scope, no more, no less.
325     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
326         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
327             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
328                 unless exists $params->{$scope_level};
329         } else {
330             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
331                 if exists $params->{$scope_level};
332         }
333     }
334
335     my $branchcode   = $params->{branchcode};
336     my $categorycode = $params->{categorycode};
337     my $itemtype     = $params->{itemtype};
338     my $rule_name    = $params->{rule_name};
339     my $rule_value   = $params->{rule_value};
340     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
341     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
342
343     for my $v ( $branchcode, $categorycode, $itemtype ) {
344         $v = undef if $v and $v eq '*';
345     }
346     my $rule = $self->search(
347         {
348             rule_name    => $rule_name,
349             branchcode   => $branchcode,
350             categorycode => $categorycode,
351             itemtype     => $itemtype,
352         }
353     )->next();
354
355     if ($rule) {
356         if ( defined $rule_value ) {
357             $rule->rule_value($rule_value);
358             $rule->update();
359         }
360         else {
361             $rule->delete();
362         }
363     }
364     else {
365         if ( defined $rule_value ) {
366             $rule = Koha::CirculationRule->new(
367                 {
368                     branchcode   => $branchcode,
369                     categorycode => $categorycode,
370                     itemtype     => $itemtype,
371                     rule_name    => $rule_name,
372                     rule_value   => $rule_value,
373                 }
374             );
375             $rule->store();
376         }
377     }
378
379     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
380     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
381       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
382
383     Koha::Cache::Memory::Lite->flush();
384
385     return $rule;
386 }
387
388 =head3 set_rules
389
390 =cut
391
392 sub set_rules {
393     my ( $self, $params ) = @_;
394
395     my %set_params;
396     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
397     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
398     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
399     my $rules        = $params->{rules};
400
401     my $rule_objects = [];
402     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
403         my $rule_object = Koha::CirculationRules->set_rule(
404             {
405                 %set_params,
406                 rule_name    => $rule_name,
407                 rule_value   => $rule_value,
408             }
409         );
410         push( @$rule_objects, $rule_object );
411     }
412
413     Koha::Cache::Memory::Lite->flush();
414     return $rule_objects;
415 }
416
417 =head3 delete
418
419 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
420
421 =cut
422
423 sub delete {
424     my ( $self ) = @_;
425
426     while ( my $rule = $self->next ){
427         $rule->delete;
428     }
429 }
430
431 =head3 clone
432
433 Clone a set of circulation rules to another branch
434
435 =cut
436
437 sub clone {
438     my ( $self, $to_branch ) = @_;
439
440     while ( my $rule = $self->next ){
441         $rule->clone($to_branch);
442     }
443 }
444
445 =head3 get_opacitemholds_policy
446
447 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
448
449 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
450 and the "Item level holds" (opacitemholds).
451 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
452
453 =cut
454
455 sub get_opacitemholds_policy {
456     my ( $class, $params ) = @_;
457
458     my $item   = $params->{item};
459     my $patron = $params->{patron};
460
461     return unless $item or $patron;
462
463     my $rule = Koha::CirculationRules->get_effective_rule(
464         {
465             categorycode => $patron->categorycode,
466             itemtype     => $item->effective_itemtype,
467             branchcode   => $item->homebranch,
468             rule_name    => 'opacitemholds',
469         }
470     );
471
472     return $rule ? $rule->rule_value : undef;
473 }
474
475 =head3 get_onshelfholds_policy
476
477     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
478
479 =cut
480
481 sub get_onshelfholds_policy {
482     my ( $class, $params ) = @_;
483     my $item = $params->{item};
484     my $itemtype = $item->effective_itemtype;
485     my $patron = $params->{patron};
486     my $rule = Koha::CirculationRules->get_effective_rule(
487         {
488             categorycode => ( $patron ? $patron->categorycode : undef ),
489             itemtype     => $itemtype,
490             branchcode   => $item->holdingbranch,
491             rule_name    => 'onshelfholds',
492         }
493     );
494     return $rule ? $rule->rule_value : 0;
495 }
496
497 =head3 get_lostreturn_policy
498
499   my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
500
501 Return values are:
502
503 =over 2
504
505 =item '0' - Do not refund
506
507 =item 'refund' - Refund the lost item charge
508
509 =item 'restore' - Refund the lost item charge and restore the original overdue fine
510
511 =item 'charge' - Refund the lost item charge and charge a new overdue fine
512
513 =back
514
515 =cut
516
517 sub get_lostreturn_policy {
518     my ( $class, $params ) = @_;
519
520     my $item   = $params->{item};
521
522     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
523     my $behaviour_mapping = {
524         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
525         ItemHomeBranch    => $item->homebranch,
526         ItemHoldingBranch => $item->holdingbranch
527     };
528
529     my $branch = $behaviour_mapping->{ $behaviour };
530
531     my $rule = Koha::CirculationRules->get_effective_rule(
532         {
533             branchcode => $branch,
534             rule_name  => 'lostreturn',
535         }
536     );
537
538     return $rule ? $rule->rule_value : 'refund';
539 }
540
541 =head3 article_requestable_rules
542
543     Return rules that allow article requests, optionally filtered by
544     patron categorycode.
545
546     Use with care; see guess_article_requestable_itemtypes.
547
548 =cut
549
550 sub article_requestable_rules {
551     my ( $class, $params ) = @_;
552     my $category = $params->{categorycode};
553
554     return if !C4::Context->preference('ArticleRequests');
555     return $class->search({
556         $category ? ( categorycode => [ $category, undef ] ) : (),
557         rule_name => 'article_requests',
558         rule_value => { '!=' => 'no' },
559     });
560 }
561
562 =head3 guess_article_requestable_itemtypes
563
564     Return item types in a hashref that are likely possible to be
565     'article requested'. Constructed by an intelligent guess in the
566     issuing rules (see article_requestable_rules).
567
568     Note: pref ArticleRequestsLinkControl overrides the algorithm.
569
570     Optional parameters: categorycode.
571
572     Note: the routine is used in opac-search to obtain a reasonable
573     estimate within performance borders (not looking at all items but
574     just using default itemtype). Also we are not looking at the
575     branchcode here, since home or holding branch of the item is
576     leading and branch may be unknown too (anonymous opac session).
577
578 =cut
579
580 sub guess_article_requestable_itemtypes {
581     my ( $class, $params ) = @_;
582     my $category = $params->{categorycode};
583     return {} if !C4::Context->preference('ArticleRequests');
584     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
585
586     my $cache = Koha::Caches->get_instance;
587     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
588     my $key = $category || '*';
589     return $last_article_requestable_guesses->{$key}
590         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
591
592     my $res = {};
593     my $rules = $class->article_requestable_rules({
594         $category ? ( categorycode => $category ) : (),
595     });
596     return $res if !$rules;
597     foreach my $rule ( $rules->as_list ) {
598         $res->{ $rule->itemtype // '*' } = 1;
599     }
600     $last_article_requestable_guesses->{$key} = $res;
601     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
602     return $res;
603 }
604
605 =head3 get_daysmode_effective_value
606
607 Return the value for daysmode defined in the circulation rules.
608 If not defined (or empty string), the value of the system preference useDaysMode is returned
609
610 =cut
611
612 sub get_effective_daysmode {
613     my ( $class, $params ) = @_;
614
615     my $categorycode     = $params->{categorycode};
616     my $itemtype         = $params->{itemtype};
617     my $branchcode       = $params->{branchcode};
618
619     my $daysmode_rule = $class->get_effective_rule(
620         {
621             categorycode => $categorycode,
622             itemtype     => $itemtype,
623             branchcode   => $branchcode,
624             rule_name    => 'daysmode',
625         }
626     );
627
628     return ( defined($daysmode_rule)
629           and $daysmode_rule->rule_value ne '' )
630       ? $daysmode_rule->rule_value
631       : C4::Context->preference('useDaysMode');
632
633 }
634
635
636 =head3 type
637
638 =cut
639
640 sub _type {
641     return 'CirculationRule';
642 }
643
644 =head3 object_class
645
646 =cut
647
648 sub object_class {
649     return 'Koha::CirculationRule';
650 }
651
652 1;