b9fc11c94168d51d5e89f5a456d7900d5b79be86
[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 =head3 get_opacitemholds_policy
478
479 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
480
481 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
482 and the "Item level holds" (opacitemholds).
483 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
484
485 =cut
486
487 sub get_opacitemholds_policy {
488     my ( $class, $params ) = @_;
489
490     my $item   = $params->{item};
491     my $patron = $params->{patron};
492
493     return unless $item or $patron;
494
495     my $rule = Koha::CirculationRules->get_effective_rule(
496         {
497             categorycode => $patron->categorycode,
498             itemtype     => $item->effective_itemtype,
499             branchcode   => $item->homebranch,
500             rule_name    => 'opacitemholds',
501         }
502     );
503
504     return $rule ? $rule->rule_value : undef;
505 }
506
507 =head3 get_onshelfholds_policy
508
509     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
510
511 =cut
512
513 sub get_onshelfholds_policy {
514     my ( $class, $params ) = @_;
515     my $item = $params->{item};
516     my $itemtype = $item->effective_itemtype;
517     my $patron = $params->{patron};
518     my $rule = Koha::CirculationRules->get_effective_rule(
519         {
520             categorycode => ( $patron ? $patron->categorycode : undef ),
521             itemtype     => $itemtype,
522             branchcode   => $item->holdingbranch,
523             rule_name    => 'onshelfholds',
524         }
525     );
526     return $rule ? $rule->rule_value : 0;
527 }
528
529 =head3 get_lostreturn_policy
530
531   my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
532
533 Return values are:
534
535 =over 2
536
537 =item '0' - Do not refund
538
539 =item 'refund' - Refund the lost item charge
540
541 =item 'restore' - Refund the lost item charge and restore the original overdue fine
542
543 =item 'charge' - Refund the lost item charge and charge a new overdue fine
544
545 =back
546
547 =cut
548
549 sub get_lostreturn_policy {
550     my ( $class, $params ) = @_;
551
552     my $item   = $params->{item};
553
554     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
555     my $behaviour_mapping = {
556         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
557         ItemHomeBranch    => $item->homebranch,
558         ItemHoldingBranch => $item->holdingbranch
559     };
560
561     my $branch = $behaviour_mapping->{ $behaviour };
562
563     my $rule = Koha::CirculationRules->get_effective_rule(
564         {
565             branchcode => $branch,
566             rule_name  => 'lostreturn',
567         }
568     );
569
570     return $rule ? $rule->rule_value : 'refund';
571 }
572
573 =head3 article_requestable_rules
574
575     Return rules that allow article requests, optionally filtered by
576     patron categorycode.
577
578     Use with care; see guess_article_requestable_itemtypes.
579
580 =cut
581
582 sub article_requestable_rules {
583     my ( $class, $params ) = @_;
584     my $category = $params->{categorycode};
585
586     return if !C4::Context->preference('ArticleRequests');
587     return $class->search({
588         $category ? ( categorycode => [ $category, undef ] ) : (),
589         rule_name => 'article_requests',
590         rule_value => { '!=' => 'no' },
591     });
592 }
593
594 =head3 guess_article_requestable_itemtypes
595
596     Return item types in a hashref that are likely possible to be
597     'article requested'. Constructed by an intelligent guess in the
598     issuing rules (see article_requestable_rules).
599
600     Note: pref ArticleRequestsLinkControl overrides the algorithm.
601
602     Optional parameters: categorycode.
603
604     Note: the routine is used in opac-search to obtain a reasonable
605     estimate within performance borders (not looking at all items but
606     just using default itemtype). Also we are not looking at the
607     branchcode here, since home or holding branch of the item is
608     leading and branch may be unknown too (anonymous opac session).
609
610 =cut
611
612 sub guess_article_requestable_itemtypes {
613     my ( $class, $params ) = @_;
614     my $category = $params->{categorycode};
615     return {} if !C4::Context->preference('ArticleRequests');
616     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
617
618     my $cache = Koha::Caches->get_instance;
619     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
620     my $key = $category || '*';
621     return $last_article_requestable_guesses->{$key}
622         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
623
624     my $res = {};
625     my $rules = $class->article_requestable_rules({
626         $category ? ( categorycode => $category ) : (),
627     });
628     return $res if !$rules;
629     foreach my $rule ( $rules->as_list ) {
630         $res->{ $rule->itemtype // '*' } = 1;
631     }
632     $last_article_requestable_guesses->{$key} = $res;
633     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
634     return $res;
635 }
636
637 =head3 get_effective_daysmode
638
639 Return the value for daysmode defined in the circulation rules.
640 If not defined (or empty string), the value of the system preference useDaysMode is returned
641
642 =cut
643
644 sub get_effective_daysmode {
645     my ( $class, $params ) = @_;
646
647     my $categorycode     = $params->{categorycode};
648     my $itemtype         = $params->{itemtype};
649     my $branchcode       = $params->{branchcode};
650
651     my $daysmode_rule = $class->get_effective_rule(
652         {
653             categorycode => $categorycode,
654             itemtype     => $itemtype,
655             branchcode   => $branchcode,
656             rule_name    => 'daysmode',
657         }
658     );
659
660     return ( defined($daysmode_rule)
661           and $daysmode_rule->rule_value ne '' )
662       ? $daysmode_rule->rule_value
663       : C4::Context->preference('useDaysMode');
664
665 }
666
667
668 =head3 type
669
670 =cut
671
672 sub _type {
673     return 'CirculationRule';
674 }
675
676 =head3 object_class
677
678 =cut
679
680 sub object_class {
681     return 'Koha::CirculationRule';
682 }
683
684 1;