Bug 33028: Fix calculations around cronjob fines.pl
[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     },
106     finedays => {
107         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
108     },
109     firstremind => {
110         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
111     },
112     hardduedate => {
113         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
114     },
115     hardduedatecompare => {
116         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117     },
118     waiting_hold_cancellation => {
119         scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
120         can_be_blank => 0,
121     },
122     holds_per_day => {
123         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
124     },
125     holds_per_record => {
126         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127     },
128     issuelength => {
129         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130     },
131     daysmode => {
132         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133     },
134     lengthunit => {
135         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136     },
137     maxissueqty => {
138         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139     },
140     maxonsiteissueqty => {
141         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142     },
143     maxsuspensiondays => {
144         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145     },
146     no_auto_renewal_after => {
147         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148     },
149     no_auto_renewal_after_hard_limit => {
150         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151     },
152     norenewalbefore => {
153         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154     },
155     onshelfholds => {
156         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157     },
158     opacitemholds => {
159         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160     },
161     overduefinescap => {
162         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163     },
164     renewalperiod => {
165         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
166     },
167     renewalsallowed => {
168         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
169     },
170     unseen_renewals_allowed => {
171         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
172     },
173     rentaldiscount => {
174         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
175         can_be_blank => 0,
176     },
177     reservesallowed => {
178         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
179     },
180     suspension_chargeperiod => {
181         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
182     },
183     note => { # This is not really a rule. Maybe we will want to separate this later.
184         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
185     },
186     decreaseloanholds => {
187         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
188     },
189     recalls_allowed => {
190         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
191     },
192     recalls_per_record => {
193         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
194     },
195     on_shelf_recalls => {
196         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
197     },
198     recall_due_date_interval => {
199         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
200     },
201     recall_overdue_fine => {
202         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
203     },
204     recall_shelf_time => {
205         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
206     },
207     # Not included (deprecated?):
208     #   * accountsent
209     #   * reservecharge
210     #   * restrictedtype
211 };
212
213 sub rule_kinds {
214     return $RULE_KINDS;
215 }
216
217 =head3 get_effective_rule
218
219   my $effective_rule = Koha::CirculationRules->get_effective_rule(
220     {
221         rule_name    => $name,
222         categorycode => $categorycode,
223         itemtype     => $itemtype,
224         branchcode   => $branchcode
225     }
226   );
227
228 Return the effective rule object for the rule associated with the criteria passed.
229
230
231 =cut
232
233 sub get_effective_rule {
234     my ( $self, $params ) = @_;
235
236     $params->{categorycode} //= undef;
237     $params->{branchcode}   //= undef;
238     $params->{itemtype}     //= undef;
239
240     my $rule_name    = $params->{rule_name};
241     my $categorycode = $params->{categorycode};
242     my $itemtype     = $params->{itemtype};
243     my $branchcode   = $params->{branchcode};
244
245     Koha::Exceptions::MissingParameter->throw(
246         "Required parameter 'rule_name' missing")
247       unless $rule_name;
248
249     for my $v ( $branchcode, $categorycode, $itemtype ) {
250         $v = undef if $v and $v eq '*';
251     }
252
253     my $order_by = $params->{order_by}
254       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
255
256     my $search_params;
257     $search_params->{rule_name} = $rule_name;
258
259     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
260     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
261     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
262
263     my $rule = $self->search(
264         $search_params,
265         {
266             order_by => $order_by,
267             rows => 1,
268         }
269     )->single;
270
271     return $rule;
272 }
273
274 =head3 get_effective_rule_value
275
276   my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
277     {
278         rule_name    => $name,
279         categorycode => $categorycode,
280         itemtype     => $itemtype,
281         branchcode   => $branchcode
282     }
283   );
284
285 Return the effective value for the rule associated with the criteria passed.
286
287 This is a cached method so should be used in preference to get_effective_rule where possible
288 to aid performance.
289
290 =cut
291
292 sub get_effective_rule_value {
293     my ( $self, $params ) = @_;
294
295     my $rule_name    = $params->{rule_name};
296     my $categorycode = $params->{categorycode};
297     my $itemtype     = $params->{itemtype};
298     my $branchcode   = $params->{branchcode};
299
300     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
301     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
302       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
303
304     my $cached       = $memory_cache->get_from_cache($cache_key);
305     return $cached if $cached;
306
307     my $rule = $self->get_effective_rule($params);
308
309     my $value= $rule ? $rule->rule_value : undef;
310     $memory_cache->set_in_cache( $cache_key, $value );
311     return $value;
312 }
313
314 =head3 get_effective_rules
315
316 =cut
317
318 sub get_effective_rules {
319     my ( $self, $params ) = @_;
320
321     my $rules        = $params->{rules};
322     my $categorycode = $params->{categorycode};
323     my $itemtype     = $params->{itemtype};
324     my $branchcode   = $params->{branchcode};
325
326     my $r;
327     foreach my $rule (@$rules) {
328         my $effective_rule = $self->get_effective_rule_value(
329             {
330                 rule_name    => $rule,
331                 categorycode => $categorycode,
332                 itemtype     => $itemtype,
333                 branchcode   => $branchcode,
334             }
335         );
336
337         $r->{$rule} = $effective_rule if defined $effective_rule;
338     }
339
340     return $r;
341 }
342
343 =head3 set_rule
344
345 =cut
346
347 sub set_rule {
348     my ( $self, $params ) = @_;
349
350     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
351         Koha::Exceptions::MissingParameter->throw(
352             "Required parameter '$mandatory_parameter' missing")
353           unless exists $params->{$mandatory_parameter};
354     }
355
356     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
357     Koha::Exceptions::MissingParameter->throw(
358         "set_rule given unknown rule '$params->{rule_name}'!")
359         unless defined $kind_info;
360
361     # Enforce scope; a rule should be set for its defined scope, no more, no less.
362     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
363         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
364             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
365                 unless exists $params->{$scope_level};
366         } else {
367             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
368                 if exists $params->{$scope_level};
369         }
370     }
371
372     my $branchcode   = $params->{branchcode};
373     my $categorycode = $params->{categorycode};
374     my $itemtype     = $params->{itemtype};
375     my $rule_name    = $params->{rule_name};
376     my $rule_value   = $params->{rule_value};
377     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
378     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
379
380     for my $v ( $branchcode, $categorycode, $itemtype ) {
381         $v = undef if $v and $v eq '*';
382     }
383     my $rule = $self->search(
384         {
385             rule_name    => $rule_name,
386             branchcode   => $branchcode,
387             categorycode => $categorycode,
388             itemtype     => $itemtype,
389         }
390     )->next();
391
392     if ( $rule
393     && ( $rule->rule_name eq 'overduefinescap' || $rule->rule_name eq 'fine' ) )
394     {
395         $rule_value = Koha::Number::Price->new($rule_value)->unformat;
396     }
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;