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' ],
88 open_article_requests_limit => {
89 scope => [ 'branchcode', 'categorycode' ],
93 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
95 cap_fine_to_replacement_price => {
96 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
99 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
101 chargeperiod_charge_at => {
102 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
105 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
109 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
112 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
115 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117 hardduedatecompare => {
118 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
120 waiting_hold_cancellation => {
121 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
125 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127 holds_per_record => {
128 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
131 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
134 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
137 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
140 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142 maxonsiteissueqty => {
143 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145 maxsuspensiondays => {
146 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148 no_auto_renewal_after => {
149 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151 no_auto_renewal_after_hard_limit => {
152 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
155 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
158 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
161 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
164 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
168 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
171 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
173 unseen_renewals_allowed => {
174 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
177 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
181 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
183 suspension_chargeperiod => {
184 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
186 note => { # This is not really a rule. Maybe we will want to separate this later.
187 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
189 decreaseloanholds => {
190 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
193 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
195 recalls_per_record => {
196 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
198 on_shelf_recalls => {
199 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
201 recall_due_date_interval => {
202 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
204 recall_overdue_fine => {
205 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
208 recall_shelf_time => {
209 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
211 # Not included (deprecated?):
221 =head3 get_effective_rule
223 my $effective_rule = Koha::CirculationRules->get_effective_rule(
226 categorycode => $categorycode,
227 itemtype => $itemtype,
228 branchcode => $branchcode
232 Return the effective rule object for the rule associated with the criteria passed.
237 sub get_effective_rule {
238 my ( $self, $params ) = @_;
240 $params->{categorycode} //= undef;
241 $params->{branchcode} //= undef;
242 $params->{itemtype} //= undef;
244 my $rule_name = $params->{rule_name};
245 my $categorycode = $params->{categorycode};
246 my $itemtype = $params->{itemtype};
247 my $branchcode = $params->{branchcode};
249 Koha::Exceptions::MissingParameter->throw(
250 "Required parameter 'rule_name' missing")
253 for my $v ( $branchcode, $categorycode, $itemtype ) {
254 $v = undef if $v and $v eq '*';
257 my $order_by = $params->{order_by}
258 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
261 $search_params->{rule_name} = $rule_name;
263 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
264 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
265 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
267 my $rule = $self->search(
270 order_by => $order_by,
278 =head3 get_effective_rule_value
280 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
283 categorycode => $categorycode,
284 itemtype => $itemtype,
285 branchcode => $branchcode
289 Return the effective value for the rule associated with the criteria passed.
291 This is a cached method so should be used in preference to get_effective_rule where possible
296 sub get_effective_rule_value {
297 my ( $self, $params ) = @_;
299 my $rule_name = $params->{rule_name};
300 my $categorycode = $params->{categorycode};
301 my $itemtype = $params->{itemtype};
302 my $branchcode = $params->{branchcode};
304 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
305 my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
306 $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
308 my $cached = $memory_cache->get_from_cache($cache_key);
309 return $cached if $cached;
311 my $rule = $self->get_effective_rule($params);
313 my $value= $rule ? $rule->rule_value : undef;
314 $memory_cache->set_in_cache( $cache_key, $value );
318 =head3 get_effective_rules
322 sub get_effective_rules {
323 my ( $self, $params ) = @_;
325 my $rules = $params->{rules};
326 my $categorycode = $params->{categorycode};
327 my $itemtype = $params->{itemtype};
328 my $branchcode = $params->{branchcode};
331 foreach my $rule (@$rules) {
332 my $effective_rule = $self->get_effective_rule_value(
335 categorycode => $categorycode,
336 itemtype => $itemtype,
337 branchcode => $branchcode,
341 $r->{$rule} = $effective_rule if defined $effective_rule;
352 my ( $self, $params ) = @_;
354 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
355 Koha::Exceptions::MissingParameter->throw(
356 "Required parameter '$mandatory_parameter' missing")
357 unless exists $params->{$mandatory_parameter};
360 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
361 Koha::Exceptions::MissingParameter->throw(
362 "set_rule given unknown rule '$params->{rule_name}'!")
363 unless defined $kind_info;
365 # Enforce scope; a rule should be set for its defined scope, no more, no less.
366 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
367 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
368 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
369 unless exists $params->{$scope_level};
371 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
372 if exists $params->{$scope_level};
376 my $branchcode = $params->{branchcode};
377 my $categorycode = $params->{categorycode};
378 my $itemtype = $params->{itemtype};
379 my $rule_name = $params->{rule_name};
380 my $rule_value = $params->{rule_value};
381 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
382 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
383 my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
384 Koha::Exceptions::BadParameter->throw("set_rule expected decimal")
385 if ( $is_monetary && defined($rule_value) && $rule_value !~ /^\d+(\.\d{2})?$/ );
387 for my $v ( $branchcode, $categorycode, $itemtype ) {
388 $v = undef if $v and $v eq '*';
390 my $rule = $self->search(
392 rule_name => $rule_name,
393 branchcode => $branchcode,
394 categorycode => $categorycode,
395 itemtype => $itemtype,
400 if ( defined $rule_value ) {
401 $rule->rule_value($rule_value);
409 if ( defined $rule_value ) {
410 $rule = Koha::CirculationRule->new(
412 branchcode => $branchcode,
413 categorycode => $categorycode,
414 itemtype => $itemtype,
415 rule_name => $rule_name,
416 rule_value => $rule_value,
423 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
424 for my $k ( $memory_cache->all_keys ) {
425 $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
436 my ( $self, $params ) = @_;
439 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
440 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
441 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
442 my $rules = $params->{rules};
444 my $rule_objects = [];
445 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
446 my $rule_object = Koha::CirculationRules->set_rule(
449 rule_name => $rule_name,
450 rule_value => $rule_value,
453 push( @$rule_objects, $rule_object );
456 return $rule_objects;
461 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
468 while ( my $rule = $self->next ){
475 Clone a set of circulation rules to another branch
480 my ( $self, $to_branch ) = @_;
482 while ( my $rule = $self->next ){
483 $rule->clone($to_branch);
487 =head2 get_return_branch_policy
489 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
491 Returns the branch to use for returning the item based on the
492 item type, and a branch selected via CircControlReturnsBranch.
494 The return value is the branch to which to return the item. Possible values:
495 noreturn: do not return, let item remain where checked in (floating collections)
496 homebranch: return to item's home branch
497 holdingbranch: return to issuer branch
499 This searches branchitemrules in the following order:
500 * Same branchcode and itemtype
501 * Same branchcode, itemtype '*'
502 * branchcode '*', same itemtype
503 * branchcode '*' and itemtype '*'
507 sub get_return_branch_policy {
508 my ( $self, $item ) = @_;
510 my $pref = C4::Context->preference('CircControlReturnsBranch');
513 $pref eq 'ItemHomeLibrary' ? $item->homebranch
514 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
515 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
516 ? C4::Context->userenv->{branch}
520 my $itemtype = $item->effective_itemtype;
522 my $rule = Koha::CirculationRules->get_effective_rule(
524 rule_name => 'returnbranch',
525 itemtype => $itemtype,
526 branchcode => $branchcode,
530 return $rule ? $rule->rule_value : 'homebranch';
534 =head3 get_opacitemholds_policy
536 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
538 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
539 and the "Item level holds" (opacitemholds).
540 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
544 sub get_opacitemholds_policy {
545 my ( $class, $params ) = @_;
547 my $item = $params->{item};
548 my $patron = $params->{patron};
550 return unless $item or $patron;
552 my $rule = Koha::CirculationRules->get_effective_rule(
554 categorycode => $patron->categorycode,
555 itemtype => $item->effective_itemtype,
556 branchcode => $item->homebranch,
557 rule_name => 'opacitemholds',
561 return $rule ? $rule->rule_value : undef;
564 =head3 get_onshelfholds_policy
566 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
570 sub get_onshelfholds_policy {
571 my ( $class, $params ) = @_;
572 my $item = $params->{item};
573 my $itemtype = $item->effective_itemtype;
574 my $patron = $params->{patron};
575 my $rule = Koha::CirculationRules->get_effective_rule(
577 categorycode => ( $patron ? $patron->categorycode : undef ),
578 itemtype => $itemtype,
579 branchcode => $item->holdingbranch,
580 rule_name => 'onshelfholds',
583 return $rule ? $rule->rule_value : 0;
586 =head3 get_lostreturn_policy
588 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
590 lostreturn return values are:
594 =item '0' - Do not refund
596 =item 'refund' - Refund the lost item charge
598 =item 'restore' - Refund the lost item charge and restore the original overdue fine
600 =item 'charge' - Refund the lost item charge and charge a new overdue fine
604 processing return return values are:
608 =item '0' - Do not refund
610 =item 'refund' - Refund the lost item processing charge
612 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
614 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
621 sub get_lostreturn_policy {
622 my ( $class, $params ) = @_;
624 my $item = $params->{item};
626 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
627 my $behaviour_mapping = {
628 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
629 ItemHomeBranch => $item->homebranch,
630 ItemHoldingBranch => $item->holdingbranch
633 my $branch = $behaviour_mapping->{ $behaviour };
635 my $rules = Koha::CirculationRules->get_effective_rules(
637 branchcode => $branch,
638 rules => ['lostreturn','processingreturn']
642 $rules->{lostreturn} //= 'refund';
643 $rules->{processingreturn} //= 'refund';
647 =head3 article_requestable_rules
649 Return rules that allow article requests, optionally filtered by
652 Use with care; see guess_article_requestable_itemtypes.
656 sub article_requestable_rules {
657 my ( $class, $params ) = @_;
658 my $category = $params->{categorycode};
660 return if !C4::Context->preference('ArticleRequests');
661 return $class->search({
662 $category ? ( categorycode => [ $category, undef ] ) : (),
663 rule_name => 'article_requests',
664 rule_value => { '!=' => 'no' },
668 =head3 guess_article_requestable_itemtypes
670 Return item types in a hashref that are likely possible to be
671 'article requested'. Constructed by an intelligent guess in the
672 issuing rules (see article_requestable_rules).
674 Note: pref ArticleRequestsLinkControl overrides the algorithm.
676 Optional parameters: categorycode.
678 Note: the routine is used in opac-search to obtain a reasonable
679 estimate within performance borders (not looking at all items but
680 just using default itemtype). Also we are not looking at the
681 branchcode here, since home or holding branch of the item is
682 leading and branch may be unknown too (anonymous opac session).
686 sub guess_article_requestable_itemtypes {
687 my ( $class, $params ) = @_;
688 my $category = $params->{categorycode};
689 return {} if !C4::Context->preference('ArticleRequests');
690 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
692 my $cache = Koha::Caches->get_instance;
693 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
694 my $key = $category || '*';
695 return $last_article_requestable_guesses->{$key}
696 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
699 my $rules = $class->article_requestable_rules({
700 $category ? ( categorycode => $category ) : (),
702 return $res if !$rules;
703 foreach my $rule ( $rules->as_list ) {
704 $res->{ $rule->itemtype // '*' } = 1;
706 $last_article_requestable_guesses->{$key} = $res;
707 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
711 =head3 get_effective_daysmode
713 Return the value for daysmode defined in the circulation rules.
714 If not defined (or empty string), the value of the system preference useDaysMode is returned
718 sub get_effective_daysmode {
719 my ( $class, $params ) = @_;
721 my $categorycode = $params->{categorycode};
722 my $itemtype = $params->{itemtype};
723 my $branchcode = $params->{branchcode};
725 my $daysmode_rule = $class->get_effective_rule(
727 categorycode => $categorycode,
728 itemtype => $itemtype,
729 branchcode => $branchcode,
730 rule_name => 'daysmode',
734 return ( defined($daysmode_rule)
735 and $daysmode_rule->rule_value ne '' )
736 ? $daysmode_rule->rule_value
737 : C4::Context->preference('useDaysMode');
747 return 'CirculationRule';
755 return 'Koha::CirculationRule';