Bug 29623: Don't flush the whole L1 cache
[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     for my $k ( $memory_cache->all_keys ) {
381         $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
382     }
383
384     return $rule;
385 }
386
387 =head3 set_rules
388
389 =cut
390
391 sub set_rules {
392     my ( $self, $params ) = @_;
393
394     my %set_params;
395     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
396     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
397     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
398     my $rules        = $params->{rules};
399
400     my $rule_objects = [];
401     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
402         my $rule_object = Koha::CirculationRules->set_rule(
403             {
404                 %set_params,
405                 rule_name    => $rule_name,
406                 rule_value   => $rule_value,
407             }
408         );
409         push( @$rule_objects, $rule_object );
410     }
411
412     return $rule_objects;
413 }
414
415 =head3 delete
416
417 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
418
419 =cut
420
421 sub delete {
422     my ( $self ) = @_;
423
424     while ( my $rule = $self->next ){
425         $rule->delete;
426     }
427 }
428
429 =head3 clone
430
431 Clone a set of circulation rules to another branch
432
433 =cut
434
435 sub clone {
436     my ( $self, $to_branch ) = @_;
437
438     while ( my $rule = $self->next ){
439         $rule->clone($to_branch);
440     }
441 }
442
443 =head3 get_opacitemholds_policy
444
445 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
446
447 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
448 and the "Item level holds" (opacitemholds).
449 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
450
451 =cut
452
453 sub get_opacitemholds_policy {
454     my ( $class, $params ) = @_;
455
456     my $item   = $params->{item};
457     my $patron = $params->{patron};
458
459     return unless $item or $patron;
460
461     my $rule = Koha::CirculationRules->get_effective_rule(
462         {
463             categorycode => $patron->categorycode,
464             itemtype     => $item->effective_itemtype,
465             branchcode   => $item->homebranch,
466             rule_name    => 'opacitemholds',
467         }
468     );
469
470     return $rule ? $rule->rule_value : undef;
471 }
472
473 =head3 get_onshelfholds_policy
474
475     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
476
477 =cut
478
479 sub get_onshelfholds_policy {
480     my ( $class, $params ) = @_;
481     my $item = $params->{item};
482     my $itemtype = $item->effective_itemtype;
483     my $patron = $params->{patron};
484     my $rule = Koha::CirculationRules->get_effective_rule(
485         {
486             categorycode => ( $patron ? $patron->categorycode : undef ),
487             itemtype     => $itemtype,
488             branchcode   => $item->holdingbranch,
489             rule_name    => 'onshelfholds',
490         }
491     );
492     return $rule ? $rule->rule_value : 0;
493 }
494
495 =head3 get_lostreturn_policy
496
497   my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
498
499 Return values are:
500
501 =over 2
502
503 =item '0' - Do not refund
504
505 =item 'refund' - Refund the lost item charge
506
507 =item 'restore' - Refund the lost item charge and restore the original overdue fine
508
509 =item 'charge' - Refund the lost item charge and charge a new overdue fine
510
511 =back
512
513 =cut
514
515 sub get_lostreturn_policy {
516     my ( $class, $params ) = @_;
517
518     my $item   = $params->{item};
519
520     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
521     my $behaviour_mapping = {
522         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
523         ItemHomeBranch    => $item->homebranch,
524         ItemHoldingBranch => $item->holdingbranch
525     };
526
527     my $branch = $behaviour_mapping->{ $behaviour };
528
529     my $rule = Koha::CirculationRules->get_effective_rule(
530         {
531             branchcode => $branch,
532             rule_name  => 'lostreturn',
533         }
534     );
535
536     return $rule ? $rule->rule_value : 'refund';
537 }
538
539 =head3 article_requestable_rules
540
541     Return rules that allow article requests, optionally filtered by
542     patron categorycode.
543
544     Use with care; see guess_article_requestable_itemtypes.
545
546 =cut
547
548 sub article_requestable_rules {
549     my ( $class, $params ) = @_;
550     my $category = $params->{categorycode};
551
552     return if !C4::Context->preference('ArticleRequests');
553     return $class->search({
554         $category ? ( categorycode => [ $category, undef ] ) : (),
555         rule_name => 'article_requests',
556         rule_value => { '!=' => 'no' },
557     });
558 }
559
560 =head3 guess_article_requestable_itemtypes
561
562     Return item types in a hashref that are likely possible to be
563     'article requested'. Constructed by an intelligent guess in the
564     issuing rules (see article_requestable_rules).
565
566     Note: pref ArticleRequestsLinkControl overrides the algorithm.
567
568     Optional parameters: categorycode.
569
570     Note: the routine is used in opac-search to obtain a reasonable
571     estimate within performance borders (not looking at all items but
572     just using default itemtype). Also we are not looking at the
573     branchcode here, since home or holding branch of the item is
574     leading and branch may be unknown too (anonymous opac session).
575
576 =cut
577
578 sub guess_article_requestable_itemtypes {
579     my ( $class, $params ) = @_;
580     my $category = $params->{categorycode};
581     return {} if !C4::Context->preference('ArticleRequests');
582     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
583
584     my $cache = Koha::Caches->get_instance;
585     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
586     my $key = $category || '*';
587     return $last_article_requestable_guesses->{$key}
588         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
589
590     my $res = {};
591     my $rules = $class->article_requestable_rules({
592         $category ? ( categorycode => $category ) : (),
593     });
594     return $res if !$rules;
595     foreach my $rule ( $rules->as_list ) {
596         $res->{ $rule->itemtype // '*' } = 1;
597     }
598     $last_article_requestable_guesses->{$key} = $res;
599     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
600     return $res;
601 }
602
603 =head3 get_daysmode_effective_value
604
605 Return the value for daysmode defined in the circulation rules.
606 If not defined (or empty string), the value of the system preference useDaysMode is returned
607
608 =cut
609
610 sub get_effective_daysmode {
611     my ( $class, $params ) = @_;
612
613     my $categorycode     = $params->{categorycode};
614     my $itemtype         = $params->{itemtype};
615     my $branchcode       = $params->{branchcode};
616
617     my $daysmode_rule = $class->get_effective_rule(
618         {
619             categorycode => $categorycode,
620             itemtype     => $itemtype,
621             branchcode   => $branchcode,
622             rule_name    => 'daysmode',
623         }
624     );
625
626     return ( defined($daysmode_rule)
627           and $daysmode_rule->rule_value ne '' )
628       ? $daysmode_rule->rule_value
629       : C4::Context->preference('useDaysMode');
630
631 }
632
633
634 =head3 type
635
636 =cut
637
638 sub _type {
639     return 'CirculationRule';
640 }
641
642 =head3 object_class
643
644 =cut
645
646 sub object_class {
647     return 'Koha::CirculationRule';
648 }
649
650 1;