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