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 # Not included (deprecated?):
226 =head3 get_effective_rule
228 my $effective_rule = Koha::CirculationRules->get_effective_rule(
231 categorycode => $categorycode,
232 itemtype => $itemtype,
233 branchcode => $branchcode
237 Return the effective rule object for the rule associated with the criteria passed.
242 sub get_effective_rule {
243 my ( $self, $params ) = @_;
245 $params->{categorycode} //= undef;
246 $params->{branchcode} //= undef;
247 $params->{itemtype} //= undef;
249 my $rule_name = $params->{rule_name};
250 my $categorycode = $params->{categorycode};
251 my $itemtype = $params->{itemtype};
252 my $branchcode = $params->{branchcode};
254 Koha::Exceptions::MissingParameter->throw(
255 "Required parameter 'rule_name' missing")
258 for my $v ( $branchcode, $categorycode, $itemtype ) {
259 $v = undef if $v and $v eq '*';
262 my $order_by = $params->{order_by}
263 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
266 $search_params->{rule_name} = $rule_name;
268 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
269 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
270 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
272 my $rule = $self->search(
275 order_by => $order_by,
283 =head3 get_effective_rule_value
285 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
288 categorycode => $categorycode,
289 itemtype => $itemtype,
290 branchcode => $branchcode
294 Return the effective value for the rule associated with the criteria passed.
296 This is a cached method so should be used in preference to get_effective_rule where possible
301 sub get_effective_rule_value {
302 my ( $self, $params ) = @_;
304 my $rule_name = $params->{rule_name};
305 my $categorycode = $params->{categorycode};
306 my $itemtype = $params->{itemtype};
307 my $branchcode = $params->{branchcode};
309 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
310 my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
311 $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
313 my $cached = $memory_cache->get_from_cache($cache_key);
314 return $cached if $cached;
316 my $rule = $self->get_effective_rule($params);
318 my $value= $rule ? $rule->rule_value : undef;
319 $memory_cache->set_in_cache( $cache_key, $value );
323 =head3 get_effective_rules
327 sub get_effective_rules {
328 my ( $self, $params ) = @_;
330 my $rules = $params->{rules};
331 my $categorycode = $params->{categorycode};
332 my $itemtype = $params->{itemtype};
333 my $branchcode = $params->{branchcode};
336 foreach my $rule (@$rules) {
337 my $effective_rule = $self->get_effective_rule_value(
340 categorycode => $categorycode,
341 itemtype => $itemtype,
342 branchcode => $branchcode,
346 $r->{$rule} = $effective_rule if defined $effective_rule;
357 my ( $self, $params ) = @_;
359 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
360 Koha::Exceptions::MissingParameter->throw(
361 "Required parameter '$mandatory_parameter' missing")
362 unless exists $params->{$mandatory_parameter};
365 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
366 Koha::Exceptions::MissingParameter->throw(
367 "set_rule given unknown rule '$params->{rule_name}'!")
368 unless defined $kind_info;
370 # Enforce scope; a rule should be set for its defined scope, no more, no less.
371 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
372 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
373 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
374 unless exists $params->{$scope_level};
376 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
377 if exists $params->{$scope_level};
381 my $branchcode = $params->{branchcode};
382 my $categorycode = $params->{categorycode};
383 my $itemtype = $params->{itemtype};
384 my $rule_name = $params->{rule_name};
385 my $rule_value = $params->{rule_value};
386 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
387 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
388 my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
389 Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
390 if ( $is_monetary && defined($rule_value) && $rule_value ne '' && $rule_value !~ /^\d+(\.\d+)?$/ );
392 for my $v ( $branchcode, $categorycode, $itemtype ) {
393 $v = undef if $v and $v eq '*';
395 my $rule = $self->search(
397 rule_name => $rule_name,
398 branchcode => $branchcode,
399 categorycode => $categorycode,
400 itemtype => $itemtype,
405 if ( defined $rule_value ) {
406 $rule->rule_value($rule_value);
414 if ( defined $rule_value ) {
415 $rule = Koha::CirculationRule->new(
417 branchcode => $branchcode,
418 categorycode => $categorycode,
419 itemtype => $itemtype,
420 rule_name => $rule_name,
421 rule_value => $rule_value,
428 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
429 for my $k ( $memory_cache->all_keys ) {
430 $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
441 my ( $self, $params ) = @_;
444 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
445 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
446 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
447 my $rules = $params->{rules};
449 my $rule_objects = [];
450 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
451 my $rule_object = Koha::CirculationRules->set_rule(
454 rule_name => $rule_name,
455 rule_value => $rule_value,
458 push( @$rule_objects, $rule_object );
461 return $rule_objects;
466 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
473 while ( my $rule = $self->next ){
480 Clone a set of circulation rules to another branch
485 my ( $self, $to_branch ) = @_;
487 while ( my $rule = $self->next ){
488 $rule->clone($to_branch);
492 =head2 get_return_branch_policy
494 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
496 Returns the branch to use for returning the item based on the
497 item type, and a branch selected via CircControlReturnsBranch.
499 The return value is the branch to which to return the item. Possible values:
500 noreturn: do not return, let item remain where checked in (floating collections)
501 homebranch: return to item's home branch
502 holdingbranch: return to issuer branch
504 This searches branchitemrules in the following order:
505 * Same branchcode and itemtype
506 * Same branchcode, itemtype '*'
507 * branchcode '*', same itemtype
508 * branchcode '*' and itemtype '*'
512 sub get_return_branch_policy {
513 my ( $self, $item ) = @_;
515 my $pref = C4::Context->preference('CircControlReturnsBranch');
518 $pref eq 'ItemHomeLibrary' ? $item->homebranch
519 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
520 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
521 ? C4::Context->userenv->{branch}
525 my $itemtype = $item->effective_itemtype;
527 my $rule = Koha::CirculationRules->get_effective_rule(
529 rule_name => 'returnbranch',
530 itemtype => $itemtype,
531 branchcode => $branchcode,
535 return $rule ? $rule->rule_value : 'homebranch';
539 =head3 get_opacitemholds_policy
541 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
543 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
544 and the "Item level holds" (opacitemholds).
545 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
549 sub get_opacitemholds_policy {
550 my ( $class, $params ) = @_;
552 my $item = $params->{item};
553 my $patron = $params->{patron};
555 return unless $item or $patron;
557 my $rule = Koha::CirculationRules->get_effective_rule(
559 categorycode => $patron->categorycode,
560 itemtype => $item->effective_itemtype,
561 branchcode => $item->homebranch,
562 rule_name => 'opacitemholds',
566 return $rule ? $rule->rule_value : undef;
569 =head3 get_onshelfholds_policy
571 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
575 sub get_onshelfholds_policy {
576 my ( $class, $params ) = @_;
577 my $item = $params->{item};
578 my $itemtype = $item->effective_itemtype;
579 my $patron = $params->{patron};
580 my $rule = Koha::CirculationRules->get_effective_rule(
582 categorycode => ( $patron ? $patron->categorycode : undef ),
583 itemtype => $itemtype,
584 branchcode => $item->holdingbranch,
585 rule_name => 'onshelfholds',
588 return $rule ? $rule->rule_value : 0;
591 =head3 get_lostreturn_policy
593 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
595 lostreturn return values are:
599 =item '0' - Do not refund
601 =item 'refund' - Refund the lost item charge
603 =item 'restore' - Refund the lost item charge and restore the original overdue fine
605 =item 'charge' - Refund the lost item charge and charge a new overdue fine
609 processing return return values are:
613 =item '0' - Do not refund
615 =item 'refund' - Refund the lost item processing charge
617 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
619 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
626 sub get_lostreturn_policy {
627 my ( $class, $params ) = @_;
629 my $item = $params->{item};
631 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
632 my $behaviour_mapping = {
633 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
634 ItemHomeBranch => $item->homebranch,
635 ItemHoldingBranch => $item->holdingbranch
638 my $branch = $behaviour_mapping->{ $behaviour };
640 my $rules = Koha::CirculationRules->get_effective_rules(
642 branchcode => $branch,
643 rules => ['lostreturn','processingreturn']
647 $rules->{lostreturn} //= 'refund';
648 $rules->{processingreturn} //= 'refund';
652 =head3 article_requestable_rules
654 Return rules that allow article requests, optionally filtered by
657 Use with care; see guess_article_requestable_itemtypes.
661 sub article_requestable_rules {
662 my ( $class, $params ) = @_;
663 my $category = $params->{categorycode};
665 return if !C4::Context->preference('ArticleRequests');
666 return $class->search({
667 $category ? ( categorycode => [ $category, undef ] ) : (),
668 rule_name => 'article_requests',
669 rule_value => { '!=' => 'no' },
673 =head3 guess_article_requestable_itemtypes
675 Return item types in a hashref that are likely possible to be
676 'article requested'. Constructed by an intelligent guess in the
677 issuing rules (see article_requestable_rules).
679 Note: pref ArticleRequestsLinkControl overrides the algorithm.
681 Optional parameters: categorycode.
683 Note: the routine is used in opac-search to obtain a reasonable
684 estimate within performance borders (not looking at all items but
685 just using default itemtype). Also we are not looking at the
686 branchcode here, since home or holding branch of the item is
687 leading and branch may be unknown too (anonymous opac session).
691 sub guess_article_requestable_itemtypes {
692 my ( $class, $params ) = @_;
693 my $category = $params->{categorycode};
694 return {} if !C4::Context->preference('ArticleRequests');
695 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
697 my $cache = Koha::Caches->get_instance;
698 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
699 my $key = $category || '*';
700 return $last_article_requestable_guesses->{$key}
701 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
704 my $rules = $class->article_requestable_rules({
705 $category ? ( categorycode => $category ) : (),
707 return $res if !$rules;
708 foreach my $rule ( $rules->as_list ) {
709 $res->{ $rule->itemtype // '*' } = 1;
711 $last_article_requestable_guesses->{$key} = $res;
712 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
716 =head3 get_effective_daysmode
718 Return the value for daysmode defined in the circulation rules.
719 If not defined (or empty string), the value of the system preference useDaysMode is returned
723 sub get_effective_daysmode {
724 my ( $class, $params ) = @_;
726 my $categorycode = $params->{categorycode};
727 my $itemtype = $params->{itemtype};
728 my $branchcode = $params->{branchcode};
730 my $daysmode_rule = $class->get_effective_rule(
732 categorycode => $categorycode,
733 itemtype => $itemtype,
734 branchcode => $branchcode,
735 rule_name => 'daysmode',
739 return ( defined($daysmode_rule)
740 and $daysmode_rule->rule_value ne '' )
741 ? $daysmode_rule->rule_value
742 : C4::Context->preference('useDaysMode');
752 return 'CirculationRule';
760 return 'Koha::CirculationRule';