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' ],
170 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
173 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
175 unseen_renewals_allowed => {
176 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
179 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
183 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
185 suspension_chargeperiod => {
186 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
188 note => { # This is not really a rule. Maybe we will want to separate this later.
189 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
191 decreaseloanholds => {
192 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
195 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
197 recalls_per_record => {
198 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
200 on_shelf_recalls => {
201 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
203 recall_due_date_interval => {
204 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
206 recall_overdue_fine => {
207 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
210 recall_shelf_time => {
211 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
213 # Not included (deprecated?):
223 =head3 get_effective_rule
225 my $effective_rule = Koha::CirculationRules->get_effective_rule(
228 categorycode => $categorycode,
229 itemtype => $itemtype,
230 branchcode => $branchcode
234 Return the effective rule object for the rule associated with the criteria passed.
239 sub get_effective_rule {
240 my ( $self, $params ) = @_;
242 $params->{categorycode} //= undef;
243 $params->{branchcode} //= undef;
244 $params->{itemtype} //= undef;
246 my $rule_name = $params->{rule_name};
247 my $categorycode = $params->{categorycode};
248 my $itemtype = $params->{itemtype};
249 my $branchcode = $params->{branchcode};
251 Koha::Exceptions::MissingParameter->throw(
252 "Required parameter 'rule_name' missing")
255 for my $v ( $branchcode, $categorycode, $itemtype ) {
256 $v = undef if $v and $v eq '*';
259 my $order_by = $params->{order_by}
260 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
263 $search_params->{rule_name} = $rule_name;
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;
269 my $rule = $self->search(
272 order_by => $order_by,
280 =head3 get_effective_rule_value
282 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
285 categorycode => $categorycode,
286 itemtype => $itemtype,
287 branchcode => $branchcode
291 Return the effective value for the rule associated with the criteria passed.
293 This is a cached method so should be used in preference to get_effective_rule where possible
298 sub get_effective_rule_value {
299 my ( $self, $params ) = @_;
301 my $rule_name = $params->{rule_name};
302 my $categorycode = $params->{categorycode};
303 my $itemtype = $params->{itemtype};
304 my $branchcode = $params->{branchcode};
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{};
310 my $cached = $memory_cache->get_from_cache($cache_key);
311 return $cached if $cached;
313 my $rule = $self->get_effective_rule($params);
315 my $value= $rule ? $rule->rule_value : undef;
316 $memory_cache->set_in_cache( $cache_key, $value );
320 =head3 get_effective_rules
324 sub get_effective_rules {
325 my ( $self, $params ) = @_;
327 my $rules = $params->{rules};
328 my $categorycode = $params->{categorycode};
329 my $itemtype = $params->{itemtype};
330 my $branchcode = $params->{branchcode};
333 foreach my $rule (@$rules) {
334 my $effective_rule = $self->get_effective_rule_value(
337 categorycode => $categorycode,
338 itemtype => $itemtype,
339 branchcode => $branchcode,
343 $r->{$rule} = $effective_rule if defined $effective_rule;
354 my ( $self, $params ) = @_;
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};
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;
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};
373 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
374 if exists $params->{$scope_level};
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+)?$/ );
389 for my $v ( $branchcode, $categorycode, $itemtype ) {
390 $v = undef if $v and $v eq '*';
392 my $rule = $self->search(
394 rule_name => $rule_name,
395 branchcode => $branchcode,
396 categorycode => $categorycode,
397 itemtype => $itemtype,
402 if ( defined $rule_value ) {
403 $rule->rule_value($rule_value);
411 if ( defined $rule_value ) {
412 $rule = Koha::CirculationRule->new(
414 branchcode => $branchcode,
415 categorycode => $categorycode,
416 itemtype => $itemtype,
417 rule_name => $rule_name,
418 rule_value => $rule_value,
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:};
438 my ( $self, $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};
446 my $rule_objects = [];
447 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
448 my $rule_object = Koha::CirculationRules->set_rule(
451 rule_name => $rule_name,
452 rule_value => $rule_value,
455 push( @$rule_objects, $rule_object );
458 return $rule_objects;
463 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
470 while ( my $rule = $self->next ){
477 Clone a set of circulation rules to another branch
482 my ( $self, $to_branch ) = @_;
484 while ( my $rule = $self->next ){
485 $rule->clone($to_branch);
489 =head2 get_return_branch_policy
491 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
493 Returns the branch to use for returning the item based on the
494 item type, and a branch selected via CircControlReturnsBranch.
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
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 '*'
509 sub get_return_branch_policy {
510 my ( $self, $item ) = @_;
512 my $pref = C4::Context->preference('CircControlReturnsBranch');
515 $pref eq 'ItemHomeLibrary' ? $item->homebranch
516 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
517 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
518 ? C4::Context->userenv->{branch}
522 my $itemtype = $item->effective_itemtype;
524 my $rule = Koha::CirculationRules->get_effective_rule(
526 rule_name => 'returnbranch',
527 itemtype => $itemtype,
528 branchcode => $branchcode,
532 return $rule ? $rule->rule_value : 'homebranch';
536 =head3 get_opacitemholds_policy
538 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
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
546 sub get_opacitemholds_policy {
547 my ( $class, $params ) = @_;
549 my $item = $params->{item};
550 my $patron = $params->{patron};
552 return unless $item or $patron;
554 my $rule = Koha::CirculationRules->get_effective_rule(
556 categorycode => $patron->categorycode,
557 itemtype => $item->effective_itemtype,
558 branchcode => $item->homebranch,
559 rule_name => 'opacitemholds',
563 return $rule ? $rule->rule_value : undef;
566 =head3 get_onshelfholds_policy
568 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
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(
579 categorycode => ( $patron ? $patron->categorycode : undef ),
580 itemtype => $itemtype,
581 branchcode => $item->holdingbranch,
582 rule_name => 'onshelfholds',
585 return $rule ? $rule->rule_value : 0;
588 =head3 get_lostreturn_policy
590 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
592 lostreturn return values are:
596 =item '0' - Do not refund
598 =item 'refund' - Refund the lost item charge
600 =item 'restore' - Refund the lost item charge and restore the original overdue fine
602 =item 'charge' - Refund the lost item charge and charge a new overdue fine
606 processing return return values are:
610 =item '0' - Do not refund
612 =item 'refund' - Refund the lost item processing charge
614 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
616 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
623 sub get_lostreturn_policy {
624 my ( $class, $params ) = @_;
626 my $item = $params->{item};
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
635 my $branch = $behaviour_mapping->{ $behaviour };
637 my $rules = Koha::CirculationRules->get_effective_rules(
639 branchcode => $branch,
640 rules => ['lostreturn','processingreturn']
644 $rules->{lostreturn} //= 'refund';
645 $rules->{processingreturn} //= 'refund';
649 =head3 article_requestable_rules
651 Return rules that allow article requests, optionally filtered by
654 Use with care; see guess_article_requestable_itemtypes.
658 sub article_requestable_rules {
659 my ( $class, $params ) = @_;
660 my $category = $params->{categorycode};
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' },
670 =head3 guess_article_requestable_itemtypes
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).
676 Note: pref ArticleRequestsLinkControl overrides the algorithm.
678 Optional parameters: categorycode.
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).
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';
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};
701 my $rules = $class->article_requestable_rules({
702 $category ? ( categorycode => $category ) : (),
704 return $res if !$rules;
705 foreach my $rule ( $rules->as_list ) {
706 $res->{ $rule->itemtype // '*' } = 1;
708 $last_article_requestable_guesses->{$key} = $res;
709 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
713 =head3 get_effective_daysmode
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
720 sub get_effective_daysmode {
721 my ( $class, $params ) = @_;
723 my $categorycode = $params->{categorycode};
724 my $itemtype = $params->{itemtype};
725 my $branchcode = $params->{branchcode};
727 my $daysmode_rule = $class->get_effective_rule(
729 categorycode => $categorycode,
730 itemtype => $itemtype,
731 branchcode => $branchcode,
732 rule_name => 'daysmode',
736 return ( defined($daysmode_rule)
737 and $daysmode_rule->rule_value ne '' )
738 ? $daysmode_rule->rule_value
739 : C4::Context->preference('useDaysMode');
749 return 'CirculationRule';
757 return 'Koha::CirculationRule';