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' ],
159 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
162 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
165 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
169 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
172 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
174 unseen_renewals_allowed => {
175 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
178 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
182 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
184 suspension_chargeperiod => {
185 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
187 note => { # This is not really a rule. Maybe we will want to separate this later.
188 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
190 decreaseloanholds => {
191 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
194 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
196 recalls_per_record => {
197 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
199 on_shelf_recalls => {
200 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
202 recall_due_date_interval => {
203 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
205 recall_overdue_fine => {
206 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
209 recall_shelf_time => {
210 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
212 # Not included (deprecated?):
222 =head3 get_effective_rule
224 my $effective_rule = Koha::CirculationRules->get_effective_rule(
227 categorycode => $categorycode,
228 itemtype => $itemtype,
229 branchcode => $branchcode
233 Return the effective rule object for the rule associated with the criteria passed.
238 sub get_effective_rule {
239 my ( $self, $params ) = @_;
241 $params->{categorycode} //= undef;
242 $params->{branchcode} //= undef;
243 $params->{itemtype} //= undef;
245 my $rule_name = $params->{rule_name};
246 my $categorycode = $params->{categorycode};
247 my $itemtype = $params->{itemtype};
248 my $branchcode = $params->{branchcode};
250 Koha::Exceptions::MissingParameter->throw(
251 "Required parameter 'rule_name' missing")
254 for my $v ( $branchcode, $categorycode, $itemtype ) {
255 $v = undef if $v and $v eq '*';
258 my $order_by = $params->{order_by}
259 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
262 $search_params->{rule_name} = $rule_name;
264 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
265 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
266 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
268 my $rule = $self->search(
271 order_by => $order_by,
279 =head3 get_effective_rule_value
281 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
284 categorycode => $categorycode,
285 itemtype => $itemtype,
286 branchcode => $branchcode
290 Return the effective value for the rule associated with the criteria passed.
292 This is a cached method so should be used in preference to get_effective_rule where possible
297 sub get_effective_rule_value {
298 my ( $self, $params ) = @_;
300 my $rule_name = $params->{rule_name};
301 my $categorycode = $params->{categorycode};
302 my $itemtype = $params->{itemtype};
303 my $branchcode = $params->{branchcode};
305 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
306 my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
307 $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
309 my $cached = $memory_cache->get_from_cache($cache_key);
310 return $cached if $cached;
312 my $rule = $self->get_effective_rule($params);
314 my $value= $rule ? $rule->rule_value : undef;
315 $memory_cache->set_in_cache( $cache_key, $value );
319 =head3 get_effective_rules
323 sub get_effective_rules {
324 my ( $self, $params ) = @_;
326 my $rules = $params->{rules};
327 my $categorycode = $params->{categorycode};
328 my $itemtype = $params->{itemtype};
329 my $branchcode = $params->{branchcode};
332 foreach my $rule (@$rules) {
333 my $effective_rule = $self->get_effective_rule_value(
336 categorycode => $categorycode,
337 itemtype => $itemtype,
338 branchcode => $branchcode,
342 $r->{$rule} = $effective_rule if defined $effective_rule;
353 my ( $self, $params ) = @_;
355 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
356 Koha::Exceptions::MissingParameter->throw(
357 "Required parameter '$mandatory_parameter' missing")
358 unless exists $params->{$mandatory_parameter};
361 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
362 Koha::Exceptions::MissingParameter->throw(
363 "set_rule given unknown rule '$params->{rule_name}'!")
364 unless defined $kind_info;
366 # Enforce scope; a rule should be set for its defined scope, no more, no less.
367 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
368 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
369 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
370 unless exists $params->{$scope_level};
372 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
373 if exists $params->{$scope_level};
377 my $branchcode = $params->{branchcode};
378 my $categorycode = $params->{categorycode};
379 my $itemtype = $params->{itemtype};
380 my $rule_name = $params->{rule_name};
381 my $rule_value = $params->{rule_value};
382 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
383 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
384 my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
385 Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
386 if ( $is_monetary && defined($rule_value) && $rule_value !~ /^\d+(\.\d+)?$/ );
388 for my $v ( $branchcode, $categorycode, $itemtype ) {
389 $v = undef if $v and $v eq '*';
391 my $rule = $self->search(
393 rule_name => $rule_name,
394 branchcode => $branchcode,
395 categorycode => $categorycode,
396 itemtype => $itemtype,
401 if ( defined $rule_value ) {
402 $rule->rule_value($rule_value);
410 if ( defined $rule_value ) {
411 $rule = Koha::CirculationRule->new(
413 branchcode => $branchcode,
414 categorycode => $categorycode,
415 itemtype => $itemtype,
416 rule_name => $rule_name,
417 rule_value => $rule_value,
424 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
425 for my $k ( $memory_cache->all_keys ) {
426 $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
437 my ( $self, $params ) = @_;
440 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
441 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
442 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
443 my $rules = $params->{rules};
445 my $rule_objects = [];
446 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
447 my $rule_object = Koha::CirculationRules->set_rule(
450 rule_name => $rule_name,
451 rule_value => $rule_value,
454 push( @$rule_objects, $rule_object );
457 return $rule_objects;
462 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
469 while ( my $rule = $self->next ){
476 Clone a set of circulation rules to another branch
481 my ( $self, $to_branch ) = @_;
483 while ( my $rule = $self->next ){
484 $rule->clone($to_branch);
488 =head2 get_return_branch_policy
490 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
492 Returns the branch to use for returning the item based on the
493 item type, and a branch selected via CircControlReturnsBranch.
495 The return value is the branch to which to return the item. Possible values:
496 noreturn: do not return, let item remain where checked in (floating collections)
497 homebranch: return to item's home branch
498 holdingbranch: return to issuer branch
500 This searches branchitemrules in the following order:
501 * Same branchcode and itemtype
502 * Same branchcode, itemtype '*'
503 * branchcode '*', same itemtype
504 * branchcode '*' and itemtype '*'
508 sub get_return_branch_policy {
509 my ( $self, $item ) = @_;
511 my $pref = C4::Context->preference('CircControlReturnsBranch');
514 $pref eq 'ItemHomeLibrary' ? $item->homebranch
515 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
516 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
517 ? C4::Context->userenv->{branch}
521 my $itemtype = $item->effective_itemtype;
523 my $rule = Koha::CirculationRules->get_effective_rule(
525 rule_name => 'returnbranch',
526 itemtype => $itemtype,
527 branchcode => $branchcode,
531 return $rule ? $rule->rule_value : 'homebranch';
535 =head3 get_opacitemholds_policy
537 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
539 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
540 and the "Item level holds" (opacitemholds).
541 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
545 sub get_opacitemholds_policy {
546 my ( $class, $params ) = @_;
548 my $item = $params->{item};
549 my $patron = $params->{patron};
551 return unless $item or $patron;
553 my $rule = Koha::CirculationRules->get_effective_rule(
555 categorycode => $patron->categorycode,
556 itemtype => $item->effective_itemtype,
557 branchcode => $item->homebranch,
558 rule_name => 'opacitemholds',
562 return $rule ? $rule->rule_value : undef;
565 =head3 get_onshelfholds_policy
567 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
571 sub get_onshelfholds_policy {
572 my ( $class, $params ) = @_;
573 my $item = $params->{item};
574 my $itemtype = $item->effective_itemtype;
575 my $patron = $params->{patron};
576 my $rule = Koha::CirculationRules->get_effective_rule(
578 categorycode => ( $patron ? $patron->categorycode : undef ),
579 itemtype => $itemtype,
580 branchcode => $item->holdingbranch,
581 rule_name => 'onshelfholds',
584 return $rule ? $rule->rule_value : 0;
587 =head3 get_lostreturn_policy
589 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
591 lostreturn return values are:
595 =item '0' - Do not refund
597 =item 'refund' - Refund the lost item charge
599 =item 'restore' - Refund the lost item charge and restore the original overdue fine
601 =item 'charge' - Refund the lost item charge and charge a new overdue fine
605 processing return return values are:
609 =item '0' - Do not refund
611 =item 'refund' - Refund the lost item processing charge
613 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
615 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
622 sub get_lostreturn_policy {
623 my ( $class, $params ) = @_;
625 my $item = $params->{item};
627 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
628 my $behaviour_mapping = {
629 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
630 ItemHomeBranch => $item->homebranch,
631 ItemHoldingBranch => $item->holdingbranch
634 my $branch = $behaviour_mapping->{ $behaviour };
636 my $rules = Koha::CirculationRules->get_effective_rules(
638 branchcode => $branch,
639 rules => ['lostreturn','processingreturn']
643 $rules->{lostreturn} //= 'refund';
644 $rules->{processingreturn} //= 'refund';
648 =head3 article_requestable_rules
650 Return rules that allow article requests, optionally filtered by
653 Use with care; see guess_article_requestable_itemtypes.
657 sub article_requestable_rules {
658 my ( $class, $params ) = @_;
659 my $category = $params->{categorycode};
661 return if !C4::Context->preference('ArticleRequests');
662 return $class->search({
663 $category ? ( categorycode => [ $category, undef ] ) : (),
664 rule_name => 'article_requests',
665 rule_value => { '!=' => 'no' },
669 =head3 guess_article_requestable_itemtypes
671 Return item types in a hashref that are likely possible to be
672 'article requested'. Constructed by an intelligent guess in the
673 issuing rules (see article_requestable_rules).
675 Note: pref ArticleRequestsLinkControl overrides the algorithm.
677 Optional parameters: categorycode.
679 Note: the routine is used in opac-search to obtain a reasonable
680 estimate within performance borders (not looking at all items but
681 just using default itemtype). Also we are not looking at the
682 branchcode here, since home or holding branch of the item is
683 leading and branch may be unknown too (anonymous opac session).
687 sub guess_article_requestable_itemtypes {
688 my ( $class, $params ) = @_;
689 my $category = $params->{categorycode};
690 return {} if !C4::Context->preference('ArticleRequests');
691 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
693 my $cache = Koha::Caches->get_instance;
694 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
695 my $key = $category || '*';
696 return $last_article_requestable_guesses->{$key}
697 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
700 my $rules = $class->article_requestable_rules({
701 $category ? ( categorycode => $category ) : (),
703 return $res if !$rules;
704 foreach my $rule ( $rules->as_list ) {
705 $res->{ $rule->itemtype // '*' } = 1;
707 $last_article_requestable_guesses->{$key} = $res;
708 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
712 =head3 get_effective_daysmode
714 Return the value for daysmode defined in the circulation rules.
715 If not defined (or empty string), the value of the system preference useDaysMode is returned
719 sub get_effective_daysmode {
720 my ( $class, $params ) = @_;
722 my $categorycode = $params->{categorycode};
723 my $itemtype = $params->{itemtype};
724 my $branchcode = $params->{branchcode};
726 my $daysmode_rule = $class->get_effective_rule(
728 categorycode => $categorycode,
729 itemtype => $itemtype,
730 branchcode => $branchcode,
731 rule_name => 'daysmode',
735 return ( defined($daysmode_rule)
736 and $daysmode_rule->rule_value ne '' )
737 ? $daysmode_rule->rule_value
738 : C4::Context->preference('useDaysMode');
748 return 'CirculationRule';
756 return 'Koha::CirculationRule';