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