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