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' ],
108 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
111 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
114 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
116 hardduedatecompare => {
117 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
119 waiting_hold_cancellation => {
120 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
124 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
126 holds_per_record => {
127 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
141 maxonsiteissueqty => {
142 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
144 maxsuspensiondays => {
145 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
147 no_auto_renewal_after => {
148 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
150 no_auto_renewal_after_hard_limit => {
151 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
167 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
170 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
172 unseen_renewals_allowed => {
173 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
176 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
180 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
182 suspension_chargeperiod => {
183 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
185 note => { # This is not really a rule. Maybe we will want to separate this later.
186 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
188 decreaseloanholds => {
189 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
192 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
194 recalls_per_record => {
195 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
197 on_shelf_recalls => {
198 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
200 recall_due_date_interval => {
201 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
203 recall_overdue_fine => {
204 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
206 recall_shelf_time => {
207 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
209 # Not included (deprecated?):
219 =head3 get_effective_rule
221 my $effective_rule = Koha::CirculationRules->get_effective_rule(
224 categorycode => $categorycode,
225 itemtype => $itemtype,
226 branchcode => $branchcode
230 Return the effective rule object for the rule associated with the criteria passed.
235 sub get_effective_rule {
236 my ( $self, $params ) = @_;
238 $params->{categorycode} //= undef;
239 $params->{branchcode} //= undef;
240 $params->{itemtype} //= undef;
242 my $rule_name = $params->{rule_name};
243 my $categorycode = $params->{categorycode};
244 my $itemtype = $params->{itemtype};
245 my $branchcode = $params->{branchcode};
247 Koha::Exceptions::MissingParameter->throw(
248 "Required parameter 'rule_name' missing")
251 for my $v ( $branchcode, $categorycode, $itemtype ) {
252 $v = undef if $v and $v eq '*';
255 my $order_by = $params->{order_by}
256 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
259 $search_params->{rule_name} = $rule_name;
261 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
262 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
263 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
265 my $rule = $self->search(
268 order_by => $order_by,
276 =head3 get_effective_rule_value
278 my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
281 categorycode => $categorycode,
282 itemtype => $itemtype,
283 branchcode => $branchcode
287 Return the effective value for the rule associated with the criteria passed.
289 This is a cached method so should be used in preference to get_effective_rule where possible
294 sub get_effective_rule_value {
295 my ( $self, $params ) = @_;
297 my $rule_name = $params->{rule_name};
298 my $categorycode = $params->{categorycode};
299 my $itemtype = $params->{itemtype};
300 my $branchcode = $params->{branchcode};
302 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
303 my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
304 $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
306 my $cached = $memory_cache->get_from_cache($cache_key);
307 return $cached if $cached;
309 my $rule = $self->get_effective_rule($params);
311 my $value= $rule ? $rule->rule_value : undef;
312 $memory_cache->set_in_cache( $cache_key, $value );
316 =head3 get_effective_rules
320 sub get_effective_rules {
321 my ( $self, $params ) = @_;
323 my $rules = $params->{rules};
324 my $categorycode = $params->{categorycode};
325 my $itemtype = $params->{itemtype};
326 my $branchcode = $params->{branchcode};
329 foreach my $rule (@$rules) {
330 my $effective_rule = $self->get_effective_rule_value(
333 categorycode => $categorycode,
334 itemtype => $itemtype,
335 branchcode => $branchcode,
339 $r->{$rule} = $effective_rule if defined $effective_rule;
350 my ( $self, $params ) = @_;
352 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
353 Koha::Exceptions::MissingParameter->throw(
354 "Required parameter '$mandatory_parameter' missing")
355 unless exists $params->{$mandatory_parameter};
358 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
359 Koha::Exceptions::MissingParameter->throw(
360 "set_rule given unknown rule '$params->{rule_name}'!")
361 unless defined $kind_info;
363 # Enforce scope; a rule should be set for its defined scope, no more, no less.
364 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
365 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
366 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
367 unless exists $params->{$scope_level};
369 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
370 if exists $params->{$scope_level};
374 my $branchcode = $params->{branchcode};
375 my $categorycode = $params->{categorycode};
376 my $itemtype = $params->{itemtype};
377 my $rule_name = $params->{rule_name};
378 my $rule_value = $params->{rule_value};
379 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
380 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
381 my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
382 $rule_value = Koha::Number::Price->new($rule_value)->unformat if defined $rule_value && $is_monetary;
384 for my $v ( $branchcode, $categorycode, $itemtype ) {
385 $v = undef if $v and $v eq '*';
387 my $rule = $self->search(
389 rule_name => $rule_name,
390 branchcode => $branchcode,
391 categorycode => $categorycode,
392 itemtype => $itemtype,
397 if ( defined $rule_value ) {
398 $rule->rule_value($rule_value);
406 if ( defined $rule_value ) {
407 $rule = Koha::CirculationRule->new(
409 branchcode => $branchcode,
410 categorycode => $categorycode,
411 itemtype => $itemtype,
412 rule_name => $rule_name,
413 rule_value => $rule_value,
420 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
421 for my $k ( $memory_cache->all_keys ) {
422 $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
433 my ( $self, $params ) = @_;
436 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
437 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
438 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
439 my $rules = $params->{rules};
441 my $rule_objects = [];
442 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
443 my $rule_object = Koha::CirculationRules->set_rule(
446 rule_name => $rule_name,
447 rule_value => $rule_value,
450 push( @$rule_objects, $rule_object );
453 return $rule_objects;
458 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
465 while ( my $rule = $self->next ){
472 Clone a set of circulation rules to another branch
477 my ( $self, $to_branch ) = @_;
479 while ( my $rule = $self->next ){
480 $rule->clone($to_branch);
484 =head2 get_return_branch_policy
486 my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
488 Returns the branch to use for returning the item based on the
489 item type, and a branch selected via CircControlReturnsBranch.
491 The return value is the branch to which to return the item. Possible values:
492 noreturn: do not return, let item remain where checked in (floating collections)
493 homebranch: return to item's home branch
494 holdingbranch: return to issuer branch
496 This searches branchitemrules in the following order:
497 * Same branchcode and itemtype
498 * Same branchcode, itemtype '*'
499 * branchcode '*', same itemtype
500 * branchcode '*' and itemtype '*'
504 sub get_return_branch_policy {
505 my ( $self, $item ) = @_;
507 my $pref = C4::Context->preference('CircControlReturnsBranch');
510 $pref eq 'ItemHomeLibrary' ? $item->homebranch
511 : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
512 : $pref eq 'CheckInLibrary' ? C4::Context->userenv
513 ? C4::Context->userenv->{branch}
517 my $itemtype = $item->effective_itemtype;
519 my $rule = Koha::CirculationRules->get_effective_rule(
521 rule_name => 'returnbranch',
522 itemtype => $itemtype,
523 branchcode => $branchcode,
527 return $rule ? $rule->rule_value : 'homebranch';
531 =head3 get_opacitemholds_policy
533 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
535 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
536 and the "Item level holds" (opacitemholds).
537 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
541 sub get_opacitemholds_policy {
542 my ( $class, $params ) = @_;
544 my $item = $params->{item};
545 my $patron = $params->{patron};
547 return unless $item or $patron;
549 my $rule = Koha::CirculationRules->get_effective_rule(
551 categorycode => $patron->categorycode,
552 itemtype => $item->effective_itemtype,
553 branchcode => $item->homebranch,
554 rule_name => 'opacitemholds',
558 return $rule ? $rule->rule_value : undef;
561 =head3 get_onshelfholds_policy
563 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
567 sub get_onshelfholds_policy {
568 my ( $class, $params ) = @_;
569 my $item = $params->{item};
570 my $itemtype = $item->effective_itemtype;
571 my $patron = $params->{patron};
572 my $rule = Koha::CirculationRules->get_effective_rule(
574 categorycode => ( $patron ? $patron->categorycode : undef ),
575 itemtype => $itemtype,
576 branchcode => $item->holdingbranch,
577 rule_name => 'onshelfholds',
580 return $rule ? $rule->rule_value : 0;
583 =head3 get_lostreturn_policy
585 my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
587 lostreturn return values are:
591 =item '0' - Do not refund
593 =item 'refund' - Refund the lost item charge
595 =item 'restore' - Refund the lost item charge and restore the original overdue fine
597 =item 'charge' - Refund the lost item charge and charge a new overdue fine
601 processing return return values are:
605 =item '0' - Do not refund
607 =item 'refund' - Refund the lost item processing charge
609 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
611 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
618 sub get_lostreturn_policy {
619 my ( $class, $params ) = @_;
621 my $item = $params->{item};
623 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
624 my $behaviour_mapping = {
625 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
626 ItemHomeBranch => $item->homebranch,
627 ItemHoldingBranch => $item->holdingbranch
630 my $branch = $behaviour_mapping->{ $behaviour };
632 my $rules = Koha::CirculationRules->get_effective_rules(
634 branchcode => $branch,
635 rules => ['lostreturn','processingreturn']
639 $rules->{lostreturn} //= 'refund';
640 $rules->{processingreturn} //= 'refund';
644 =head3 article_requestable_rules
646 Return rules that allow article requests, optionally filtered by
649 Use with care; see guess_article_requestable_itemtypes.
653 sub article_requestable_rules {
654 my ( $class, $params ) = @_;
655 my $category = $params->{categorycode};
657 return if !C4::Context->preference('ArticleRequests');
658 return $class->search({
659 $category ? ( categorycode => [ $category, undef ] ) : (),
660 rule_name => 'article_requests',
661 rule_value => { '!=' => 'no' },
665 =head3 guess_article_requestable_itemtypes
667 Return item types in a hashref that are likely possible to be
668 'article requested'. Constructed by an intelligent guess in the
669 issuing rules (see article_requestable_rules).
671 Note: pref ArticleRequestsLinkControl overrides the algorithm.
673 Optional parameters: categorycode.
675 Note: the routine is used in opac-search to obtain a reasonable
676 estimate within performance borders (not looking at all items but
677 just using default itemtype). Also we are not looking at the
678 branchcode here, since home or holding branch of the item is
679 leading and branch may be unknown too (anonymous opac session).
683 sub guess_article_requestable_itemtypes {
684 my ( $class, $params ) = @_;
685 my $category = $params->{categorycode};
686 return {} if !C4::Context->preference('ArticleRequests');
687 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
689 my $cache = Koha::Caches->get_instance;
690 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
691 my $key = $category || '*';
692 return $last_article_requestable_guesses->{$key}
693 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
696 my $rules = $class->article_requestable_rules({
697 $category ? ( categorycode => $category ) : (),
699 return $res if !$rules;
700 foreach my $rule ( $rules->as_list ) {
701 $res->{ $rule->itemtype // '*' } = 1;
703 $last_article_requestable_guesses->{$key} = $res;
704 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
708 =head3 get_effective_daysmode
710 Return the value for daysmode defined in the circulation rules.
711 If not defined (or empty string), the value of the system preference useDaysMode is returned
715 sub get_effective_daysmode {
716 my ( $class, $params ) = @_;
718 my $categorycode = $params->{categorycode};
719 my $itemtype = $params->{itemtype};
720 my $branchcode = $params->{branchcode};
722 my $daysmode_rule = $class->get_effective_rule(
724 categorycode => $categorycode,
725 itemtype => $itemtype,
726 branchcode => $branchcode,
727 rule_name => 'daysmode',
731 return ( defined($daysmode_rule)
732 and $daysmode_rule->rule_value ne '' )
733 ? $daysmode_rule->rule_value
734 : C4::Context->preference('useDaysMode');
744 return 'CirculationRule';
752 return 'Koha::CirculationRule';