1 package Koha::CirculationRules;
3 # Copyright ByWater Solutions 2017
5 # This file is part of Koha.
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.
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.
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>.
24 use Koha::Exceptions::CirculationRule;
25 use Koha::CirculationRule;
27 use Koha::Cache::Memory::Lite;
28 use Koha::Number::Price;
30 use base qw(Koha::Objects);
32 use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';
36 Koha::CirculationRules - Koha CirculationRule Object set class
46 This structure describes the possible rules that may be set, and what scopes they can be set at.
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.
54 scope => [ 'branchcode' ],
57 scope => [ 'branchcode' ],
59 patron_maxissueqty => {
60 scope => [ 'branchcode', 'categorycode' ],
62 patron_maxonsiteissueqty => {
63 scope => [ 'branchcode', 'categorycode' ],
66 scope => [ 'branchcode', 'categorycode' ],
70 scope => [ 'branchcode', 'itemtype' ],
73 hold_fulfillment_policy => {
74 scope => [ 'branchcode', 'itemtype' ],
78 scope => [ 'branchcode', 'itemtype' ],
83 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
85 article_request_fee => {
86 scope => [ 'branchcode', 'categorycode' ],
89 open_article_requests_limit => {
90 scope => [ 'branchcode', 'categorycode' ],
94 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
96 cap_fine_to_replacement_price => {
97 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
100 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
102 chargeperiod_charge_at => {
103 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
106 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
110 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
113 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
116 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
118 hardduedatecompare => {
119 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
121 waiting_hold_cancellation => {
122 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
126 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
128 holds_per_record => {
129 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
132 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
135 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
138 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
141 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
143 maxonsiteissueqty => {
144 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
146 maxsuspensiondays => {
147 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
149 no_auto_renewal_after => {
150 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
152 no_auto_renewal_after_hard_limit => {
153 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
156 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
158 noautorenewalbefore => {
159 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
162 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
165 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
168 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
173 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
176 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
178 unseen_renewals_allowed => {
179 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
182 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
186 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
188 suspension_chargeperiod => {
189 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
191 note => { # This is not really a rule. Maybe we will want to separate this later.
192 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
194 decreaseloanholds => {
195 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
198 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
200 recalls_per_record => {
201 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
203 on_shelf_recalls => {
204 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
206 recall_due_date_interval => {
207 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
209 recall_overdue_fine => {
210 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
213 recall_shelf_time => {
214 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
216 holds_pickup_period => {
217 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
219 # Not included (deprecated?):
229 =head3 get_effective_rule
231 my $effective_rule = Koha::CirculationRules->get_effective_rule(
234 categorycode => $categorycode,
235 itemtype => $itemtype,
236 branchcode => $branchcode
240 Return the effective rule object for the rule associated with the criteria passed.
245 sub get_effective_rule {
246 my ( $self, $params ) = @_;
248 $params->{categorycode} //= undef;
249 $params->{branchcode} //= undef;
250 $params->{itemtype} //= undef;
252 my $rule_name = $params->{rule_name};
253 my $categorycode = $params->{categorycode};
254 my $itemtype = $params->{itemtype};
255 my $branchcode = $params->{branchcode};
257 Koha::Exceptions::MissingParameter->throw(
258 "Required parameter 'rule_name' missing")
261 for my $v ( $branchcode, $categorycode, $itemtype ) {
262 $v = undef if $v and $v eq '*';
265 my $order_by = $params->{order_by}
266 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
269 $search_params->{rule_name} = $rule_name;
271 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
272 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
273 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
275 my $rule = $self->search(
278 order_by => $order_by,
286 =head3 get_effective_rule_value
288 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
291 categorycode => $categorycode,
292 itemtype => $itemtype,
293 branchcode => $branchcode
297 Return the effective value for the rule associated with the criteria passed.
299 This is a cached method so should be used in preference to get_effective_rule where possible
304 sub get_effective_rule_value {
305 my ( $self, $params ) = @_;
307 my $rule_name = $params->{rule_name};
308 my $categorycode = $params->{categorycode};
309 my $itemtype = $params->{itemtype};
310 my $branchcode = $params->{branchcode};
312 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
313 my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
314 $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
316 my $cached = $memory_cache->get_from_cache($cache_key);
317 return $cached if $cached;
319 my $rule = $self->get_effective_rule($params);
321 my $value= $rule ? $rule->rule_value : undef;
322 $memory_cache->set_in_cache( $cache_key, $value );
326 =head3 get_effective_rules
330 sub get_effective_rules {
331 my ( $self, $params ) = @_;
333 my $rules = $params->{rules};
334 my $categorycode = $params->{categorycode};
335 my $itemtype = $params->{itemtype};
336 my $branchcode = $params->{branchcode};
339 foreach my $rule (@$rules) {
340 my $effective_rule = $self->get_effective_rule_value(
343 categorycode => $categorycode,
344 itemtype => $itemtype,
345 branchcode => $branchcode,
349 $r->{$rule} = $effective_rule if defined $effective_rule;
360 my ( $self, $params ) = @_;
362 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
363 Koha::Exceptions::MissingParameter->throw(
364 "Required parameter '$mandatory_parameter' missing")
365 unless exists $params->{$mandatory_parameter};
368 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
369 Koha::Exceptions::MissingParameter->throw(
370 "set_rule given unknown rule '$params->{rule_name}'!")
371 unless defined $kind_info;
373 # Enforce scope; a rule should be set for its defined scope, no more, no less.
374 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
375 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
376 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
377 unless exists $params->{$scope_level};
379 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
380 if exists $params->{$scope_level};
384 my $branchcode = $params->{branchcode};
385 my $categorycode = $params->{categorycode};
386 my $itemtype = $params->{itemtype};
387 my $rule_name = $params->{rule_name};
388 my $rule_value = $params->{rule_value};
389 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
390 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
391 my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
392 Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
393 if ( $is_monetary && defined($rule_value) && $rule_value ne '' && $rule_value !~ /^\d+(\.\d+)?$/ );
395 for my $v ( $branchcode, $categorycode, $itemtype ) {
396 $v = undef if $v and $v eq '*';
398 my $rule = $self->search(
400 rule_name => $rule_name,
401 branchcode => $branchcode,
402 categorycode => $categorycode,
403 itemtype => $itemtype,
408 if ( defined $rule_value ) {
409 $rule->rule_value($rule_value);
417 if ( defined $rule_value ) {
418 $rule = Koha::CirculationRule->new(
420 branchcode => $branchcode,
421 categorycode => $categorycode,
422 itemtype => $itemtype,
423 rule_name => $rule_name,
424 rule_value => $rule_value,
431 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
432 for my $k ( $memory_cache->all_keys ) {
433 $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
444 my ( $self, $params ) = @_;
447 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
448 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
449 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
450 my $rules = $params->{rules};
452 my $rule_objects = [];
453 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
454 my $rule_object = Koha::CirculationRules->set_rule(
457 rule_name => $rule_name,
458 rule_value => $rule_value,
461 push( @$rule_objects, $rule_object );
464 return $rule_objects;
469 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
476 while ( my $rule = $self->next ){
483 Clone a set of circulation rules to another branch
488 my ( $self, $to_branch ) = @_;
490 while ( my $rule = $self->next ){
491 $rule->clone($to_branch);
495 =head2 get_return_branch_policy
497 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
499 Returns the branch to use for returning the item based on the
500 item type, and a branch selected via CircControlReturnsBranch.
502 The return value is the branch to which to return the item. Possible values:
503 noreturn: do not return, let item remain where checked in (floating collections)
504 homebranch: return to item's home branch
505 holdingbranch: return to issuer branch
507 This searches branchitemrules in the following order:
508 * Same branchcode and itemtype
509 * Same branchcode, itemtype '*'
510 * branchcode '*', same itemtype
511 * branchcode '*' and itemtype '*'
515 sub get_return_branch_policy {
516 my ( $self, $item ) = @_;
518 my $pref = C4::Context->preference('CircControlReturnsBranch');
521 $pref eq 'ItemHomeLibrary' ? $item->homebranch
522 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
523 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
524 ? C4::Context->userenv->{branch}
528 my $itemtype = $item->effective_itemtype;
530 my $rule = Koha::CirculationRules->get_effective_rule(
532 rule_name => 'returnbranch',
533 itemtype => $itemtype,
534 branchcode => $branchcode,
538 return $rule ? $rule->rule_value : 'homebranch';
542 =head3 get_opacitemholds_policy
544 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
546 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
547 and the "Item level holds" (opacitemholds).
548 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
552 sub get_opacitemholds_policy {
553 my ( $class, $params ) = @_;
555 my $item = $params->{item};
556 my $patron = $params->{patron};
558 return unless $item or $patron;
560 my $rule = Koha::CirculationRules->get_effective_rule(
562 categorycode => $patron->categorycode,
563 itemtype => $item->effective_itemtype,
564 branchcode => $item->homebranch,
565 rule_name => 'opacitemholds',
569 return $rule ? $rule->rule_value : undef;
572 =head3 get_onshelfholds_policy
574 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
578 sub get_onshelfholds_policy {
579 my ( $class, $params ) = @_;
580 my $item = $params->{item};
581 my $itemtype = $item->effective_itemtype;
582 my $patron = $params->{patron};
583 my $rule = Koha::CirculationRules->get_effective_rule(
585 categorycode => ( $patron ? $patron->categorycode : undef ),
586 itemtype => $itemtype,
587 branchcode => $item->holdingbranch,
588 rule_name => 'onshelfholds',
591 return $rule ? $rule->rule_value : 0;
594 =head3 get_lostreturn_policy
596 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
598 lostreturn return values are:
602 =item '0' - Do not refund
604 =item 'refund' - Refund the lost item charge
606 =item 'restore' - Refund the lost item charge and restore the original overdue fine
608 =item 'charge' - Refund the lost item charge and charge a new overdue fine
612 processing return return values are:
616 =item '0' - Do not refund
618 =item 'refund' - Refund the lost item processing charge
620 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
622 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
629 sub get_lostreturn_policy {
630 my ( $class, $params ) = @_;
632 my $item = $params->{item};
634 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
635 my $behaviour_mapping = {
636 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
637 ItemHomeBranch => $item->homebranch,
638 ItemHoldingBranch => $item->holdingbranch
641 my $branch = $behaviour_mapping->{ $behaviour };
643 my $rules = Koha::CirculationRules->get_effective_rules(
645 branchcode => $branch,
646 rules => ['lostreturn','processingreturn']
650 $rules->{lostreturn} //= 'refund';
651 $rules->{processingreturn} //= 'refund';
655 =head3 article_requestable_rules
657 Return rules that allow article requests, optionally filtered by
660 Use with care; see guess_article_requestable_itemtypes.
664 sub article_requestable_rules {
665 my ( $class, $params ) = @_;
666 my $category = $params->{categorycode};
668 return if !C4::Context->preference('ArticleRequests');
669 return $class->search({
670 $category ? ( categorycode => [ $category, undef ] ) : (),
671 rule_name => 'article_requests',
672 rule_value => { '!=' => 'no' },
676 =head3 guess_article_requestable_itemtypes
678 Return item types in a hashref that are likely possible to be
679 'article requested'. Constructed by an intelligent guess in the
680 issuing rules (see article_requestable_rules).
682 Note: pref ArticleRequestsLinkControl overrides the algorithm.
684 Optional parameters: categorycode.
686 Note: the routine is used in opac-search to obtain a reasonable
687 estimate within performance borders (not looking at all items but
688 just using default itemtype). Also we are not looking at the
689 branchcode here, since home or holding branch of the item is
690 leading and branch may be unknown too (anonymous opac session).
694 sub guess_article_requestable_itemtypes {
695 my ( $class, $params ) = @_;
696 my $category = $params->{categorycode};
697 return {} if !C4::Context->preference('ArticleRequests');
698 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
700 my $cache = Koha::Caches->get_instance;
701 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
702 my $key = $category || '*';
703 return $last_article_requestable_guesses->{$key}
704 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
707 my $rules = $class->article_requestable_rules({
708 $category ? ( categorycode => $category ) : (),
710 return $res if !$rules;
711 foreach my $rule ( $rules->as_list ) {
712 $res->{ $rule->itemtype // '*' } = 1;
714 $last_article_requestable_guesses->{$key} = $res;
715 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
719 =head3 get_effective_daysmode
721 Return the value for daysmode defined in the circulation rules.
722 If not defined (or empty string), the value of the system preference useDaysMode is returned
726 sub get_effective_daysmode {
727 my ( $class, $params ) = @_;
729 my $categorycode = $params->{categorycode};
730 my $itemtype = $params->{itemtype};
731 my $branchcode = $params->{branchcode};
733 my $daysmode_rule = $class->get_effective_rule(
735 categorycode => $categorycode,
736 itemtype => $itemtype,
737 branchcode => $branchcode,
738 rule_name => 'daysmode',
742 return ( defined($daysmode_rule)
743 and $daysmode_rule->rule_value ne '' )
744 ? $daysmode_rule->rule_value
745 : C4::Context->preference('useDaysMode');
755 return 'CirculationRule';
763 return 'Koha::CirculationRule';