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::CirculationRule;
26 use Koha::Cache::Memory::Lite;
27 use Koha::Number::Price;
29 use base qw(Koha::Objects);
31 use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';
35 Koha::CirculationRules - Koha CirculationRule Object set class
45 This structure describes the possible rules that may be set, and what scopes they can be set at.
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.
53 scope => [ 'branchcode' ],
56 scope => [ 'branchcode' ],
58 patron_maxissueqty => {
59 scope => [ 'branchcode', 'categorycode' ],
61 patron_maxonsiteissueqty => {
62 scope => [ 'branchcode', 'categorycode' ],
65 scope => [ 'branchcode', 'categorycode' ],
69 scope => [ 'branchcode', 'itemtype' ],
72 hold_fulfillment_policy => {
73 scope => [ 'branchcode', 'itemtype' ],
77 scope => [ 'branchcode', 'itemtype' ],
82 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
84 article_request_fee => {
85 scope => [ 'branchcode', 'categorycode' ],
87 open_article_requests_limit => {
88 scope => [ 'branchcode', 'categorycode' ],
92 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
94 cap_fine_to_replacement_price => {
95 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
98 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
100 chargeperiod_charge_at => {
101 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
104 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
107 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
110 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
113 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
115 hardduedatecompare => {
116 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
118 waiting_hold_cancellation => {
119 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
123 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
125 holds_per_record => {
126 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
129 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
132 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
135 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
138 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
140 maxonsiteissueqty => {
141 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
143 maxsuspensiondays => {
144 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
146 no_auto_renewal_after => {
147 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
149 no_auto_renewal_after_hard_limit => {
150 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
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' ],
168 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
170 unseen_renewals_allowed => {
171 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
174 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
178 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
180 suspension_chargeperiod => {
181 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
183 note => { # This is not really a rule. Maybe we will want to separate this later.
184 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
186 decreaseloanholds => {
187 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
190 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
192 recalls_per_record => {
193 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
195 on_shelf_recalls => {
196 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
198 recall_due_date_interval => {
199 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
201 recall_overdue_fine => {
202 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
204 recall_shelf_time => {
205 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
207 # Not included (deprecated?):
217 =head3 get_effective_rule
219 my $effective_rule = Koha::CirculationRules->get_effective_rule(
222 categorycode => $categorycode,
223 itemtype => $itemtype,
224 branchcode => $branchcode
228 Return the effective rule object for the rule associated with the criteria passed.
233 sub get_effective_rule {
234 my ( $self, $params ) = @_;
236 $params->{categorycode} //= undef;
237 $params->{branchcode} //= undef;
238 $params->{itemtype} //= undef;
240 my $rule_name = $params->{rule_name};
241 my $categorycode = $params->{categorycode};
242 my $itemtype = $params->{itemtype};
243 my $branchcode = $params->{branchcode};
245 Koha::Exceptions::MissingParameter->throw(
246 "Required parameter 'rule_name' missing")
249 for my $v ( $branchcode, $categorycode, $itemtype ) {
250 $v = undef if $v and $v eq '*';
253 my $order_by = $params->{order_by}
254 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
257 $search_params->{rule_name} = $rule_name;
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;
263 my $rule = $self->search(
266 order_by => $order_by,
274 =head3 get_effective_rule_value
276 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
279 categorycode => $categorycode,
280 itemtype => $itemtype,
281 branchcode => $branchcode
285 Return the effective value for the rule associated with the criteria passed.
287 This is a cached method so should be used in preference to get_effective_rule where possible
292 sub get_effective_rule_value {
293 my ( $self, $params ) = @_;
295 my $rule_name = $params->{rule_name};
296 my $categorycode = $params->{categorycode};
297 my $itemtype = $params->{itemtype};
298 my $branchcode = $params->{branchcode};
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{};
304 my $cached = $memory_cache->get_from_cache($cache_key);
305 return $cached if $cached;
307 my $rule = $self->get_effective_rule($params);
309 my $value= $rule ? $rule->rule_value : undef;
310 $memory_cache->set_in_cache( $cache_key, $value );
314 =head3 get_effective_rules
318 sub get_effective_rules {
319 my ( $self, $params ) = @_;
321 my $rules = $params->{rules};
322 my $categorycode = $params->{categorycode};
323 my $itemtype = $params->{itemtype};
324 my $branchcode = $params->{branchcode};
327 foreach my $rule (@$rules) {
328 my $effective_rule = $self->get_effective_rule_value(
331 categorycode => $categorycode,
332 itemtype => $itemtype,
333 branchcode => $branchcode,
337 $r->{$rule} = $effective_rule if defined $effective_rule;
348 my ( $self, $params ) = @_;
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};
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;
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};
367 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
368 if exists $params->{$scope_level};
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;
380 for my $v ( $branchcode, $categorycode, $itemtype ) {
381 $v = undef if $v and $v eq '*';
383 my $rule = $self->search(
385 rule_name => $rule_name,
386 branchcode => $branchcode,
387 categorycode => $categorycode,
388 itemtype => $itemtype,
393 && ( $rule->rule_name eq 'overduefinescap' || $rule->rule_name eq 'fine' ) )
395 $rule_value = Koha::Number::Price->new($rule_value)->unformat;
399 if ( defined $rule_value ) {
400 $rule->rule_value($rule_value);
408 if ( defined $rule_value ) {
409 $rule = Koha::CirculationRule->new(
411 branchcode => $branchcode,
412 categorycode => $categorycode,
413 itemtype => $itemtype,
414 rule_name => $rule_name,
415 rule_value => $rule_value,
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:};
435 my ( $self, $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};
443 my $rule_objects = [];
444 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
445 my $rule_object = Koha::CirculationRules->set_rule(
448 rule_name => $rule_name,
449 rule_value => $rule_value,
452 push( @$rule_objects, $rule_object );
455 return $rule_objects;
460 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
467 while ( my $rule = $self->next ){
474 Clone a set of circulation rules to another branch
479 my ( $self, $to_branch ) = @_;
481 while ( my $rule = $self->next ){
482 $rule->clone($to_branch);
486 =head2 get_return_branch_policy
488 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
490 Returns the branch to use for returning the item based on the
491 item type, and a branch selected via CircControlReturnsBranch.
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
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 '*'
506 sub get_return_branch_policy {
507 my ( $self, $item ) = @_;
509 my $pref = C4::Context->preference('CircControlReturnsBranch');
512 $pref eq 'ItemHomeLibrary' ? $item->homebranch
513 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
514 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
515 ? C4::Context->userenv->{branch}
519 my $itemtype = $item->effective_itemtype;
521 my $rule = Koha::CirculationRules->get_effective_rule(
523 rule_name => 'returnbranch',
524 itemtype => $itemtype,
525 branchcode => $branchcode,
529 return $rule ? $rule->rule_value : 'homebranch';
533 =head3 get_opacitemholds_policy
535 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
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
543 sub get_opacitemholds_policy {
544 my ( $class, $params ) = @_;
546 my $item = $params->{item};
547 my $patron = $params->{patron};
549 return unless $item or $patron;
551 my $rule = Koha::CirculationRules->get_effective_rule(
553 categorycode => $patron->categorycode,
554 itemtype => $item->effective_itemtype,
555 branchcode => $item->homebranch,
556 rule_name => 'opacitemholds',
560 return $rule ? $rule->rule_value : undef;
563 =head3 get_onshelfholds_policy
565 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
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(
576 categorycode => ( $patron ? $patron->categorycode : undef ),
577 itemtype => $itemtype,
578 branchcode => $item->holdingbranch,
579 rule_name => 'onshelfholds',
582 return $rule ? $rule->rule_value : 0;
585 =head3 get_lostreturn_policy
587 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
589 lostreturn return values are:
593 =item '0' - Do not refund
595 =item 'refund' - Refund the lost item charge
597 =item 'restore' - Refund the lost item charge and restore the original overdue fine
599 =item 'charge' - Refund the lost item charge and charge a new overdue fine
603 processing return return values are:
607 =item '0' - Do not refund
609 =item 'refund' - Refund the lost item processing charge
611 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
613 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
620 sub get_lostreturn_policy {
621 my ( $class, $params ) = @_;
623 my $item = $params->{item};
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
632 my $branch = $behaviour_mapping->{ $behaviour };
634 my $rules = Koha::CirculationRules->get_effective_rules(
636 branchcode => $branch,
637 rules => ['lostreturn','processingreturn']
641 $rules->{lostreturn} //= 'refund';
642 $rules->{processingreturn} //= 'refund';
646 =head3 article_requestable_rules
648 Return rules that allow article requests, optionally filtered by
651 Use with care; see guess_article_requestable_itemtypes.
655 sub article_requestable_rules {
656 my ( $class, $params ) = @_;
657 my $category = $params->{categorycode};
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' },
667 =head3 guess_article_requestable_itemtypes
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).
673 Note: pref ArticleRequestsLinkControl overrides the algorithm.
675 Optional parameters: categorycode.
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).
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';
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};
698 my $rules = $class->article_requestable_rules({
699 $category ? ( categorycode => $category ) : (),
701 return $res if !$rules;
702 foreach my $rule ( $rules->as_list ) {
703 $res->{ $rule->itemtype // '*' } = 1;
705 $last_article_requestable_guesses->{$key} = $res;
706 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
710 =head3 get_effective_daysmode
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
717 sub get_effective_daysmode {
718 my ( $class, $params ) = @_;
720 my $categorycode = $params->{categorycode};
721 my $itemtype = $params->{itemtype};
722 my $branchcode = $params->{branchcode};
724 my $daysmode_rule = $class->get_effective_rule(
726 categorycode => $categorycode,
727 itemtype => $itemtype,
728 branchcode => $branchcode,
729 rule_name => 'daysmode',
733 return ( defined($daysmode_rule)
734 and $daysmode_rule->rule_value ne '' )
735 ? $daysmode_rule->rule_value
736 : C4::Context->preference('useDaysMode');
746 return 'CirculationRule';
754 return 'Koha::CirculationRule';