Bug 26922: Regression tests
[koha.git] / Koha / CirculationRules.pm
blobdf1314613e1c054ee3138460ee9081fbfbe32d5c
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>.
20 use Modern::Perl;
21 use Carp qw(croak);
23 use Koha::Exceptions;
24 use Koha::CirculationRule;
26 use base qw(Koha::Objects);
28 use constant GUESSED_ITEMTYPES_KEY => 'Koha_IssuingRules_last_guess';
30 =head1 NAME
32 Koha::CirculationRules - Koha CirculationRule Object set class
34 =head1 API
36 =head2 Class Methods
38 =cut
40 =head3 rule_kinds
42 This structure describes the possible rules that may be set, and what scopes they can be set at.
44 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.
46 =cut
48 our $RULE_KINDS = {
49 lostreturn => {
50 scope => [ 'branchcode' ],
53 patron_maxissueqty => {
54 scope => [ 'branchcode', 'categorycode' ],
56 patron_maxonsiteissueqty => {
57 scope => [ 'branchcode', 'categorycode' ],
59 max_holds => {
60 scope => [ 'branchcode', 'categorycode' ],
63 holdallowed => {
64 scope => [ 'branchcode', 'itemtype' ],
65 can_be_blank => 0,
67 hold_fulfillment_policy => {
68 scope => [ 'branchcode', 'itemtype' ],
69 can_be_blank => 0,
71 returnbranch => {
72 scope => [ 'branchcode', 'itemtype' ],
73 can_be_blank => 0,
76 article_requests => {
77 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
79 auto_renew => {
80 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
82 cap_fine_to_replacement_price => {
83 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
85 chargeperiod => {
86 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
88 chargeperiod_charge_at => {
89 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
91 fine => {
92 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
94 finedays => {
95 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
97 firstremind => {
98 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
100 hardduedate => {
101 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
103 hardduedatecompare => {
104 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
106 holds_per_day => {
107 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
109 holds_per_record => {
110 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
112 issuelength => {
113 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
115 daysmode => {
116 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
118 lengthunit => {
119 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
121 maxissueqty => {
122 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
124 maxonsiteissueqty => {
125 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127 maxsuspensiondays => {
128 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130 no_auto_renewal_after => {
131 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133 no_auto_renewal_after_hard_limit => {
134 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136 norenewalbefore => {
137 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139 onshelfholds => {
140 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142 opacitemholds => {
143 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145 overduefinescap => {
146 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148 renewalperiod => {
149 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151 renewalsallowed => {
152 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154 unseen_renewals_allowed => {
155 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157 rentaldiscount => {
158 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160 reservesallowed => {
161 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163 suspension_chargeperiod => {
164 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
166 note => { # This is not really a rule. Maybe we will want to separate this later.
167 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
169 decreaseloanholds => {
170 scope => [ 'branchcode', 'categorycode', 'itemtype' ],
172 # Not included (deprecated?):
173 # * accountsent
174 # * reservecharge
175 # * restrictedtype
178 sub rule_kinds {
179 return $RULE_KINDS;
182 =head3 get_effective_rule
184 =cut
186 sub get_effective_rule {
187 my ( $self, $params ) = @_;
189 $params->{categorycode} //= undef;
190 $params->{branchcode} //= undef;
191 $params->{itemtype} //= undef;
193 my $rule_name = $params->{rule_name};
194 my $categorycode = $params->{categorycode};
195 my $itemtype = $params->{itemtype};
196 my $branchcode = $params->{branchcode};
198 Koha::Exceptions::MissingParameter->throw(
199 "Required parameter 'rule_name' missing")
200 unless $rule_name;
202 for my $v ( $branchcode, $categorycode, $itemtype ) {
203 $v = undef if $v and $v eq '*';
206 my $order_by = $params->{order_by}
207 // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
209 my $search_params;
210 $search_params->{rule_name} = $rule_name;
212 $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
213 $search_params->{itemtype} = defined $itemtype ? [ $itemtype, undef ] : undef;
214 $search_params->{branchcode} = defined $branchcode ? [ $branchcode, undef ] : undef;
216 my $rule = $self->search(
217 $search_params,
219 order_by => $order_by,
220 rows => 1,
222 )->single;
224 return $rule;
227 =head3 get_effective_rules
229 =cut
231 sub get_effective_rules {
232 my ( $self, $params ) = @_;
234 my $rules = $params->{rules};
235 my $categorycode = $params->{categorycode};
236 my $itemtype = $params->{itemtype};
237 my $branchcode = $params->{branchcode};
239 my $r;
240 foreach my $rule (@$rules) {
241 my $effective_rule = $self->get_effective_rule(
243 rule_name => $rule,
244 categorycode => $categorycode,
245 itemtype => $itemtype,
246 branchcode => $branchcode,
250 $r->{$rule} = $effective_rule->rule_value if $effective_rule;
253 return $r;
256 =head3 set_rule
258 =cut
260 sub set_rule {
261 my ( $self, $params ) = @_;
263 for my $mandatory_parameter (qw( rule_name rule_value ) ) {
264 Koha::Exceptions::MissingParameter->throw(
265 "Required parameter '$mandatory_parameter' missing")
266 unless exists $params->{$mandatory_parameter};
269 my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
270 Koha::Exceptions::MissingParameter->throw(
271 "set_rule given unknown rule '$params->{rule_name}'!")
272 unless defined $kind_info;
274 # Enforce scope; a rule should be set for its defined scope, no more, no less.
275 foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
276 if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
277 croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
278 unless exists $params->{$scope_level};
279 } else {
280 croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
281 if exists $params->{$scope_level};
285 my $branchcode = $params->{branchcode};
286 my $categorycode = $params->{categorycode};
287 my $itemtype = $params->{itemtype};
288 my $rule_name = $params->{rule_name};
289 my $rule_value = $params->{rule_value};
290 my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
291 $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
293 for my $v ( $branchcode, $categorycode, $itemtype ) {
294 $v = undef if $v and $v eq '*';
296 my $rule = $self->search(
298 rule_name => $rule_name,
299 branchcode => $branchcode,
300 categorycode => $categorycode,
301 itemtype => $itemtype,
303 )->next();
305 if ($rule) {
306 if ( defined $rule_value ) {
307 $rule->rule_value($rule_value);
308 $rule->update();
310 else {
311 $rule->delete();
314 else {
315 if ( defined $rule_value ) {
316 $rule = Koha::CirculationRule->new(
318 branchcode => $branchcode,
319 categorycode => $categorycode,
320 itemtype => $itemtype,
321 rule_name => $rule_name,
322 rule_value => $rule_value,
325 $rule->store();
329 return $rule;
332 =head3 set_rules
334 =cut
336 sub set_rules {
337 my ( $self, $params ) = @_;
339 my %set_params;
340 $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
341 $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
342 $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
343 my $rules = $params->{rules};
345 my $rule_objects = [];
346 while ( my ( $rule_name, $rule_value ) = each %$rules ) {
347 my $rule_object = Koha::CirculationRules->set_rule(
349 %set_params,
350 rule_name => $rule_name,
351 rule_value => $rule_value,
354 push( @$rule_objects, $rule_object );
357 return $rule_objects;
360 =head3 delete
362 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
364 =cut
366 sub delete {
367 my ( $self ) = @_;
369 while ( my $rule = $self->next ){
370 $rule->delete;
374 =head3 clone
376 Clone a set of circulation rules to another branch
378 =cut
380 sub clone {
381 my ( $self, $to_branch ) = @_;
383 while ( my $rule = $self->next ){
384 $rule->clone($to_branch);
388 =head3 get_opacitemholds_policy
390 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
392 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
393 and the "Item level holds" (opacitemholds).
394 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
396 =cut
398 sub get_opacitemholds_policy {
399 my ( $class, $params ) = @_;
401 my $item = $params->{item};
402 my $patron = $params->{patron};
404 return unless $item or $patron;
406 my $rule = Koha::CirculationRules->get_effective_rule(
408 categorycode => $patron->categorycode,
409 itemtype => $item->effective_itemtype,
410 branchcode => $item->homebranch,
411 rule_name => 'opacitemholds',
415 return $rule ? $rule->rule_value : undef;
418 =head3 get_onshelfholds_policy
420 my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
422 =cut
424 sub get_onshelfholds_policy {
425 my ( $class, $params ) = @_;
426 my $item = $params->{item};
427 my $itemtype = $item->effective_itemtype;
428 my $patron = $params->{patron};
429 my $rule = Koha::CirculationRules->get_effective_rule(
431 categorycode => ( $patron ? $patron->categorycode : undef ),
432 itemtype => $itemtype,
433 branchcode => $item->holdingbranch,
434 rule_name => 'onshelfholds',
437 return $rule ? $rule->rule_value : 0;
440 =head3 get_lostreturn_policy
442 my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
444 Return values are:
446 =over 2
448 =item '0' - Do not refund
450 =item 'refund' - Refund the lost item charge
452 =item 'restore' - Refund the lost item charge and restore the original overdue fine
454 =item 'charge' - Refund the lost item charge and charge a new overdue fine
456 =back
458 =cut
460 sub get_lostreturn_policy {
461 my ( $class, $params ) = @_;
463 my $item = $params->{item};
465 my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
466 my $behaviour_mapping = {
467 CheckinLibrary => $params->{'return_branch'} // $item->homebranch,
468 ItemHomeBranch => $item->homebranch,
469 ItemHoldingBranch => $item->holdingbranch
472 my $branch = $behaviour_mapping->{ $behaviour };
474 my $rule = Koha::CirculationRules->get_effective_rule(
476 branchcode => $branch,
477 rule_name => 'lostreturn',
481 return $rule ? $rule->rule_value : 'refund';
484 =head3 article_requestable_rules
486 Return rules that allow article requests, optionally filtered by
487 patron categorycode.
489 Use with care; see guess_article_requestable_itemtypes.
491 =cut
493 sub article_requestable_rules {
494 my ( $class, $params ) = @_;
495 my $category = $params->{categorycode};
497 return if !C4::Context->preference('ArticleRequests');
498 return $class->search({
499 $category ? ( categorycode => [ $category, undef ] ) : (),
500 rule_name => 'article_requests',
501 rule_value => { '!=' => 'no' },
505 =head3 guess_article_requestable_itemtypes
507 Return item types in a hashref that are likely possible to be
508 'article requested'. Constructed by an intelligent guess in the
509 issuing rules (see article_requestable_rules).
511 Note: pref ArticleRequestsLinkControl overrides the algorithm.
513 Optional parameters: categorycode.
515 Note: the routine is used in opac-search to obtain a reasonable
516 estimate within performance borders (not looking at all items but
517 just using default itemtype). Also we are not looking at the
518 branchcode here, since home or holding branch of the item is
519 leading and branch may be unknown too (anonymous opac session).
521 =cut
523 sub guess_article_requestable_itemtypes {
524 my ( $class, $params ) = @_;
525 my $category = $params->{categorycode};
526 return {} if !C4::Context->preference('ArticleRequests');
527 return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
529 my $cache = Koha::Caches->get_instance;
530 my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
531 my $key = $category || '*';
532 return $last_article_requestable_guesses->{$key}
533 if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
535 my $res = {};
536 my $rules = $class->article_requestable_rules({
537 $category ? ( categorycode => $category ) : (),
539 return $res if !$rules;
540 foreach my $rule ( $rules->as_list ) {
541 $res->{ $rule->itemtype // '*' } = 1;
543 $last_article_requestable_guesses->{$key} = $res;
544 $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
545 return $res;
548 =head3 get_daysmode_effective_value
550 Return the value for daysmode defined in the circulation rules.
551 If not defined (or empty string), the value of the system preference useDaysMode is returned
553 =cut
555 sub get_effective_daysmode {
556 my ( $class, $params ) = @_;
558 my $categorycode = $params->{categorycode};
559 my $itemtype = $params->{itemtype};
560 my $branchcode = $params->{branchcode};
562 my $daysmode_rule = $class->get_effective_rule(
564 categorycode => $categorycode,
565 itemtype => $itemtype,
566 branchcode => $branchcode,
567 rule_name => 'daysmode',
571 return ( defined($daysmode_rule)
572 and $daysmode_rule->rule_value ne '' )
573 ? $daysmode_rule->rule_value
574 : C4::Context->preference('useDaysMode');
579 =head3 type
581 =cut
583 sub _type {
584 return 'CirculationRule';
587 =head3 object_class
589 =cut
591 sub object_class {
592 return 'Koha::CirculationRule';