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