Bug 30650: Be more flexible with opening slots
[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   my $effective_rule = Koha::CirculationRules->get_effective_rule(
213     {
214         rule_name    => $name,
215         categorycode => $categorycode,
216         itemtype     => $itemtype,
217         branchcode   => $branchcode
218     }
219   );
220
221 Return the effective rule object for the rule associated with the criteria passed.
222
223
224 =cut
225
226 sub get_effective_rule {
227     my ( $self, $params ) = @_;
228
229     $params->{categorycode} //= undef;
230     $params->{branchcode}   //= undef;
231     $params->{itemtype}     //= undef;
232
233     my $rule_name    = $params->{rule_name};
234     my $categorycode = $params->{categorycode};
235     my $itemtype     = $params->{itemtype};
236     my $branchcode   = $params->{branchcode};
237
238     Koha::Exceptions::MissingParameter->throw(
239         "Required parameter 'rule_name' missing")
240       unless $rule_name;
241
242     for my $v ( $branchcode, $categorycode, $itemtype ) {
243         $v = undef if $v and $v eq '*';
244     }
245
246     my $order_by = $params->{order_by}
247       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
248
249     my $search_params;
250     $search_params->{rule_name} = $rule_name;
251
252     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
253     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
254     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
255
256     my $rule = $self->search(
257         $search_params,
258         {
259             order_by => $order_by,
260             rows => 1,
261         }
262     )->single;
263
264     return $rule;
265 }
266
267 =head3 get_effective_rule_value
268
269   my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
270     {
271         rule_name    => $name,
272         categorycode => $categorycode,
273         itemtype     => $itemtype,
274         branchcode   => $branchcode
275     }
276   );
277
278 Return the effective value for the rule associated with the criteria passed.
279
280 This is a cached method so should be used in preference to get_effective_rule where possible
281 to aid performance.
282
283 =cut
284
285 sub get_effective_rule_value {
286     my ( $self, $params ) = @_;
287
288     my $rule_name    = $params->{rule_name};
289     my $categorycode = $params->{categorycode};
290     my $itemtype     = $params->{itemtype};
291     my $branchcode   = $params->{branchcode};
292
293     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
294     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
295       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
296
297     my $cached       = $memory_cache->get_from_cache($cache_key);
298     return $cached if $cached;
299
300     my $rule = $self->get_effective_rule($params);
301
302     my $value= $rule ? $rule->rule_value : undef;
303     $memory_cache->set_in_cache( $cache_key, $value );
304     return $value;
305 }
306
307 =head3 get_effective_rules
308
309 =cut
310
311 sub get_effective_rules {
312     my ( $self, $params ) = @_;
313
314     my $rules        = $params->{rules};
315     my $categorycode = $params->{categorycode};
316     my $itemtype     = $params->{itemtype};
317     my $branchcode   = $params->{branchcode};
318
319     my $r;
320     foreach my $rule (@$rules) {
321         my $effective_rule = $self->get_effective_rule_value(
322             {
323                 rule_name    => $rule,
324                 categorycode => $categorycode,
325                 itemtype     => $itemtype,
326                 branchcode   => $branchcode,
327             }
328         );
329
330         $r->{$rule} = $effective_rule if defined $effective_rule;
331     }
332
333     return $r;
334 }
335
336 =head3 set_rule
337
338 =cut
339
340 sub set_rule {
341     my ( $self, $params ) = @_;
342
343     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
344         Koha::Exceptions::MissingParameter->throw(
345             "Required parameter '$mandatory_parameter' missing")
346           unless exists $params->{$mandatory_parameter};
347     }
348
349     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
350     Koha::Exceptions::MissingParameter->throw(
351         "set_rule given unknown rule '$params->{rule_name}'!")
352         unless defined $kind_info;
353
354     # Enforce scope; a rule should be set for its defined scope, no more, no less.
355     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
356         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
357             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
358                 unless exists $params->{$scope_level};
359         } else {
360             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
361                 if exists $params->{$scope_level};
362         }
363     }
364
365     my $branchcode   = $params->{branchcode};
366     my $categorycode = $params->{categorycode};
367     my $itemtype     = $params->{itemtype};
368     my $rule_name    = $params->{rule_name};
369     my $rule_value   = $params->{rule_value};
370     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
371     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
372
373     for my $v ( $branchcode, $categorycode, $itemtype ) {
374         $v = undef if $v and $v eq '*';
375     }
376     my $rule = $self->search(
377         {
378             rule_name    => $rule_name,
379             branchcode   => $branchcode,
380             categorycode => $categorycode,
381             itemtype     => $itemtype,
382         }
383     )->next();
384
385     if ($rule) {
386         if ( defined $rule_value ) {
387             $rule->rule_value($rule_value);
388             $rule->update();
389         }
390         else {
391             $rule->delete();
392         }
393     }
394     else {
395         if ( defined $rule_value ) {
396             $rule = Koha::CirculationRule->new(
397                 {
398                     branchcode   => $branchcode,
399                     categorycode => $categorycode,
400                     itemtype     => $itemtype,
401                     rule_name    => $rule_name,
402                     rule_value   => $rule_value,
403                 }
404             );
405             $rule->store();
406         }
407     }
408
409     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
410     for my $k ( $memory_cache->all_keys ) {
411         $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
412     }
413
414     return $rule;
415 }
416
417 =head3 set_rules
418
419 =cut
420
421 sub set_rules {
422     my ( $self, $params ) = @_;
423
424     my %set_params;
425     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
426     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
427     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
428     my $rules        = $params->{rules};
429
430     my $rule_objects = [];
431     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
432         my $rule_object = Koha::CirculationRules->set_rule(
433             {
434                 %set_params,
435                 rule_name    => $rule_name,
436                 rule_value   => $rule_value,
437             }
438         );
439         push( @$rule_objects, $rule_object );
440     }
441
442     return $rule_objects;
443 }
444
445 =head3 delete
446
447 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
448
449 =cut
450
451 sub delete {
452     my ( $self ) = @_;
453
454     while ( my $rule = $self->next ){
455         $rule->delete;
456     }
457 }
458
459 =head3 clone
460
461 Clone a set of circulation rules to another branch
462
463 =cut
464
465 sub clone {
466     my ( $self, $to_branch ) = @_;
467
468     while ( my $rule = $self->next ){
469         $rule->clone($to_branch);
470     }
471 }
472
473 =head3 get_opacitemholds_policy
474
475 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
476
477 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
478 and the "Item level holds" (opacitemholds).
479 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
480
481 =cut
482
483 sub get_opacitemholds_policy {
484     my ( $class, $params ) = @_;
485
486     my $item   = $params->{item};
487     my $patron = $params->{patron};
488
489     return unless $item or $patron;
490
491     my $rule = Koha::CirculationRules->get_effective_rule(
492         {
493             categorycode => $patron->categorycode,
494             itemtype     => $item->effective_itemtype,
495             branchcode   => $item->homebranch,
496             rule_name    => 'opacitemholds',
497         }
498     );
499
500     return $rule ? $rule->rule_value : undef;
501 }
502
503 =head3 get_onshelfholds_policy
504
505     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
506
507 =cut
508
509 sub get_onshelfholds_policy {
510     my ( $class, $params ) = @_;
511     my $item = $params->{item};
512     my $itemtype = $item->effective_itemtype;
513     my $patron = $params->{patron};
514     my $rule = Koha::CirculationRules->get_effective_rule(
515         {
516             categorycode => ( $patron ? $patron->categorycode : undef ),
517             itemtype     => $itemtype,
518             branchcode   => $item->holdingbranch,
519             rule_name    => 'onshelfholds',
520         }
521     );
522     return $rule ? $rule->rule_value : 0;
523 }
524
525 =head3 get_lostreturn_policy
526
527   my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
528
529 Return values are:
530
531 =over 2
532
533 =item '0' - Do not refund
534
535 =item 'refund' - Refund the lost item charge
536
537 =item 'restore' - Refund the lost item charge and restore the original overdue fine
538
539 =item 'charge' - Refund the lost item charge and charge a new overdue fine
540
541 =back
542
543 =cut
544
545 sub get_lostreturn_policy {
546     my ( $class, $params ) = @_;
547
548     my $item   = $params->{item};
549
550     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
551     my $behaviour_mapping = {
552         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
553         ItemHomeBranch    => $item->homebranch,
554         ItemHoldingBranch => $item->holdingbranch
555     };
556
557     my $branch = $behaviour_mapping->{ $behaviour };
558
559     my $rule = Koha::CirculationRules->get_effective_rule(
560         {
561             branchcode => $branch,
562             rule_name  => 'lostreturn',
563         }
564     );
565
566     return $rule ? $rule->rule_value : 'refund';
567 }
568
569 =head3 article_requestable_rules
570
571     Return rules that allow article requests, optionally filtered by
572     patron categorycode.
573
574     Use with care; see guess_article_requestable_itemtypes.
575
576 =cut
577
578 sub article_requestable_rules {
579     my ( $class, $params ) = @_;
580     my $category = $params->{categorycode};
581
582     return if !C4::Context->preference('ArticleRequests');
583     return $class->search({
584         $category ? ( categorycode => [ $category, undef ] ) : (),
585         rule_name => 'article_requests',
586         rule_value => { '!=' => 'no' },
587     });
588 }
589
590 =head3 guess_article_requestable_itemtypes
591
592     Return item types in a hashref that are likely possible to be
593     'article requested'. Constructed by an intelligent guess in the
594     issuing rules (see article_requestable_rules).
595
596     Note: pref ArticleRequestsLinkControl overrides the algorithm.
597
598     Optional parameters: categorycode.
599
600     Note: the routine is used in opac-search to obtain a reasonable
601     estimate within performance borders (not looking at all items but
602     just using default itemtype). Also we are not looking at the
603     branchcode here, since home or holding branch of the item is
604     leading and branch may be unknown too (anonymous opac session).
605
606 =cut
607
608 sub guess_article_requestable_itemtypes {
609     my ( $class, $params ) = @_;
610     my $category = $params->{categorycode};
611     return {} if !C4::Context->preference('ArticleRequests');
612     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
613
614     my $cache = Koha::Caches->get_instance;
615     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
616     my $key = $category || '*';
617     return $last_article_requestable_guesses->{$key}
618         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
619
620     my $res = {};
621     my $rules = $class->article_requestable_rules({
622         $category ? ( categorycode => $category ) : (),
623     });
624     return $res if !$rules;
625     foreach my $rule ( $rules->as_list ) {
626         $res->{ $rule->itemtype // '*' } = 1;
627     }
628     $last_article_requestable_guesses->{$key} = $res;
629     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
630     return $res;
631 }
632
633 =head3 get_effective_daysmode
634
635 Return the value for daysmode defined in the circulation rules.
636 If not defined (or empty string), the value of the system preference useDaysMode is returned
637
638 =cut
639
640 sub get_effective_daysmode {
641     my ( $class, $params ) = @_;
642
643     my $categorycode     = $params->{categorycode};
644     my $itemtype         = $params->{itemtype};
645     my $branchcode       = $params->{branchcode};
646
647     my $daysmode_rule = $class->get_effective_rule(
648         {
649             categorycode => $categorycode,
650             itemtype     => $itemtype,
651             branchcode   => $branchcode,
652             rule_name    => 'daysmode',
653         }
654     );
655
656     return ( defined($daysmode_rule)
657           and $daysmode_rule->rule_value ne '' )
658       ? $daysmode_rule->rule_value
659       : C4::Context->preference('useDaysMode');
660
661 }
662
663
664 =head3 type
665
666 =cut
667
668 sub _type {
669     return 'CirculationRule';
670 }
671
672 =head3 object_class
673
674 =cut
675
676 sub object_class {
677     return 'Koha::CirculationRule';
678 }
679
680 1;