Bug 29346: (follow-up) Fix typo 'availabe'
[koha.git] / Koha / CirculationRules.pm
1 package Koha::CirculationRules;
2
3 # Copyright ByWater Solutions 2017
4 #
5 # This file is part of Koha.
6 #
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.
11 #
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.
16 #
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>.
19
20 use Modern::Perl;
21 use Carp qw( croak );
22
23 use Koha::Exceptions;
24 use Koha::CirculationRule;
25
26 use base qw(Koha::Objects);
27
28 use constant GUESSED_ITEMTYPES_KEY => 'Koha_IssuingRules_last_guess';
29
30 =head1 NAME
31
32 Koha::CirculationRules - Koha CirculationRule Object set class
33
34 =head1 API
35
36 =head2 Class Methods
37
38 =cut
39
40 =head3 rule_kinds
41
42 This structure describes the possible rules that may be set, and what scopes they can be set at.
43
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.
45
46 =cut
47
48 our $RULE_KINDS = {
49     lostreturn => {
50         scope => [ 'branchcode' ],
51     },
52
53     patron_maxissueqty => {
54         scope => [ 'branchcode', 'categorycode' ],
55     },
56     patron_maxonsiteissueqty => {
57         scope => [ 'branchcode', 'categorycode' ],
58     },
59     max_holds => {
60         scope => [ 'branchcode', 'categorycode' ],
61     },
62
63     holdallowed => {
64         scope => [ 'branchcode', 'itemtype' ],
65         can_be_blank => 0,
66     },
67     hold_fulfillment_policy => {
68         scope => [ 'branchcode', 'itemtype' ],
69         can_be_blank => 0,
70     },
71     returnbranch => {
72         scope => [ 'branchcode', 'itemtype' ],
73         can_be_blank => 0,
74     },
75
76     article_requests => {
77         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
78     },
79     article_request_fee => {
80         scope => [ 'branchcode', 'categorycode' ],
81     },
82     open_article_requests_limit => {
83         scope => [ 'branchcode', 'categorycode' ],
84     },
85
86     auto_renew => {
87         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
88     },
89     cap_fine_to_replacement_price => {
90         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
91     },
92     chargeperiod => {
93         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
94     },
95     chargeperiod_charge_at => {
96         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
97     },
98     fine => {
99         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
100     },
101     finedays => {
102         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
103     },
104     firstremind => {
105         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
106     },
107     hardduedate => {
108         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
109     },
110     hardduedatecompare => {
111         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
112     },
113     holds_per_day => {
114         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
115     },
116     holds_per_record => {
117         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
118     },
119     issuelength => {
120         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
121     },
122     daysmode => {
123         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
124     },
125     lengthunit => {
126         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
127     },
128     maxissueqty => {
129         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
130     },
131     maxonsiteissueqty => {
132         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
133     },
134     maxsuspensiondays => {
135         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
136     },
137     no_auto_renewal_after => {
138         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
139     },
140     no_auto_renewal_after_hard_limit => {
141         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
142     },
143     norenewalbefore => {
144         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
145     },
146     onshelfholds => {
147         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
148     },
149     opacitemholds => {
150         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
151     },
152     overduefinescap => {
153         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
154     },
155     renewalperiod => {
156         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
157     },
158     renewalsallowed => {
159         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
160     },
161     unseen_renewals_allowed => {
162         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
163     },
164     rentaldiscount => {
165         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
166         can_be_blank => 0,
167     },
168     reservesallowed => {
169         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
170     },
171     suspension_chargeperiod => {
172         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
173     },
174     note => { # This is not really a rule. Maybe we will want to separate this later.
175         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
176     },
177     decreaseloanholds => {
178         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
179     },
180     recalls_allowed => {
181         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
182     },
183     recalls_per_record => {
184         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
185     },
186     on_shelf_recalls => {
187         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
188     },
189     recall_due_date_interval => {
190         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
191     },
192     recall_overdue_fine => {
193         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
194     },
195     recall_shelf_time => {
196         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
197     },
198     # Not included (deprecated?):
199     #   * accountsent
200     #   * reservecharge
201     #   * restrictedtype
202 };
203
204 sub rule_kinds {
205     return $RULE_KINDS;
206 }
207
208 =head3 get_effective_rule
209
210 =cut
211
212 sub get_effective_rule {
213     my ( $self, $params ) = @_;
214
215     $params->{categorycode} //= undef;
216     $params->{branchcode}   //= undef;
217     $params->{itemtype}     //= undef;
218
219     my $rule_name    = $params->{rule_name};
220     my $categorycode = $params->{categorycode};
221     my $itemtype     = $params->{itemtype};
222     my $branchcode   = $params->{branchcode};
223
224     Koha::Exceptions::MissingParameter->throw(
225         "Required parameter 'rule_name' missing")
226       unless $rule_name;
227
228     for my $v ( $branchcode, $categorycode, $itemtype ) {
229         $v = undef if $v and $v eq '*';
230     }
231
232     my $order_by = $params->{order_by}
233       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
234
235     my $search_params;
236     $search_params->{rule_name} = $rule_name;
237
238     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
239     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
240     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
241
242     my $rule = $self->search(
243         $search_params,
244         {
245             order_by => $order_by,
246             rows => 1,
247         }
248     )->single;
249
250     return $rule;
251 }
252
253 =head3 get_effective_rules
254
255 =cut
256
257 sub get_effective_rules {
258     my ( $self, $params ) = @_;
259
260     my $rules        = $params->{rules};
261     my $categorycode = $params->{categorycode};
262     my $itemtype     = $params->{itemtype};
263     my $branchcode   = $params->{branchcode};
264
265     my $r;
266     foreach my $rule (@$rules) {
267         my $effective_rule = $self->get_effective_rule(
268             {
269                 rule_name    => $rule,
270                 categorycode => $categorycode,
271                 itemtype     => $itemtype,
272                 branchcode   => $branchcode,
273             }
274         );
275
276         $r->{$rule} = $effective_rule->rule_value if $effective_rule;
277     }
278
279     return $r;
280 }
281
282 =head3 set_rule
283
284 =cut
285
286 sub set_rule {
287     my ( $self, $params ) = @_;
288
289     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
290         Koha::Exceptions::MissingParameter->throw(
291             "Required parameter '$mandatory_parameter' missing")
292           unless exists $params->{$mandatory_parameter};
293     }
294
295     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
296     Koha::Exceptions::MissingParameter->throw(
297         "set_rule given unknown rule '$params->{rule_name}'!")
298         unless defined $kind_info;
299
300     # Enforce scope; a rule should be set for its defined scope, no more, no less.
301     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
302         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
303             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
304                 unless exists $params->{$scope_level};
305         } else {
306             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
307                 if exists $params->{$scope_level};
308         }
309     }
310
311     my $branchcode   = $params->{branchcode};
312     my $categorycode = $params->{categorycode};
313     my $itemtype     = $params->{itemtype};
314     my $rule_name    = $params->{rule_name};
315     my $rule_value   = $params->{rule_value};
316     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
317     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
318
319     for my $v ( $branchcode, $categorycode, $itemtype ) {
320         $v = undef if $v and $v eq '*';
321     }
322     my $rule = $self->search(
323         {
324             rule_name    => $rule_name,
325             branchcode   => $branchcode,
326             categorycode => $categorycode,
327             itemtype     => $itemtype,
328         }
329     )->next();
330
331     if ($rule) {
332         if ( defined $rule_value ) {
333             $rule->rule_value($rule_value);
334             $rule->update();
335         }
336         else {
337             $rule->delete();
338         }
339     }
340     else {
341         if ( defined $rule_value ) {
342             $rule = Koha::CirculationRule->new(
343                 {
344                     branchcode   => $branchcode,
345                     categorycode => $categorycode,
346                     itemtype     => $itemtype,
347                     rule_name    => $rule_name,
348                     rule_value   => $rule_value,
349                 }
350             );
351             $rule->store();
352         }
353     }
354
355     return $rule;
356 }
357
358 =head3 set_rules
359
360 =cut
361
362 sub set_rules {
363     my ( $self, $params ) = @_;
364
365     my %set_params;
366     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
367     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
368     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
369     my $rules        = $params->{rules};
370
371     my $rule_objects = [];
372     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
373         my $rule_object = Koha::CirculationRules->set_rule(
374             {
375                 %set_params,
376                 rule_name    => $rule_name,
377                 rule_value   => $rule_value,
378             }
379         );
380         push( @$rule_objects, $rule_object );
381     }
382
383     return $rule_objects;
384 }
385
386 =head3 delete
387
388 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
389
390 =cut
391
392 sub delete {
393     my ( $self ) = @_;
394
395     while ( my $rule = $self->next ){
396         $rule->delete;
397     }
398 }
399
400 =head3 clone
401
402 Clone a set of circulation rules to another branch
403
404 =cut
405
406 sub clone {
407     my ( $self, $to_branch ) = @_;
408
409     while ( my $rule = $self->next ){
410         $rule->clone($to_branch);
411     }
412 }
413
414 =head3 get_opacitemholds_policy
415
416 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
417
418 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
419 and the "Item level holds" (opacitemholds).
420 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
421
422 =cut
423
424 sub get_opacitemholds_policy {
425     my ( $class, $params ) = @_;
426
427     my $item   = $params->{item};
428     my $patron = $params->{patron};
429
430     return unless $item or $patron;
431
432     my $rule = Koha::CirculationRules->get_effective_rule(
433         {
434             categorycode => $patron->categorycode,
435             itemtype     => $item->effective_itemtype,
436             branchcode   => $item->homebranch,
437             rule_name    => 'opacitemholds',
438         }
439     );
440
441     return $rule ? $rule->rule_value : undef;
442 }
443
444 =head3 get_onshelfholds_policy
445
446     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
447
448 =cut
449
450 sub get_onshelfholds_policy {
451     my ( $class, $params ) = @_;
452     my $item = $params->{item};
453     my $itemtype = $item->effective_itemtype;
454     my $patron = $params->{patron};
455     my $rule = Koha::CirculationRules->get_effective_rule(
456         {
457             categorycode => ( $patron ? $patron->categorycode : undef ),
458             itemtype     => $itemtype,
459             branchcode   => $item->holdingbranch,
460             rule_name    => 'onshelfholds',
461         }
462     );
463     return $rule ? $rule->rule_value : 0;
464 }
465
466 =head3 get_lostreturn_policy
467
468   my $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
469
470 Return values are:
471
472 =over 2
473
474 =item '0' - Do not refund
475
476 =item 'refund' - Refund the lost item charge
477
478 =item 'restore' - Refund the lost item charge and restore the original overdue fine
479
480 =item 'charge' - Refund the lost item charge and charge a new overdue fine
481
482 =back
483
484 =cut
485
486 sub get_lostreturn_policy {
487     my ( $class, $params ) = @_;
488
489     my $item   = $params->{item};
490
491     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
492     my $behaviour_mapping = {
493         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
494         ItemHomeBranch    => $item->homebranch,
495         ItemHoldingBranch => $item->holdingbranch
496     };
497
498     my $branch = $behaviour_mapping->{ $behaviour };
499
500     my $rule = Koha::CirculationRules->get_effective_rule(
501         {
502             branchcode => $branch,
503             rule_name  => 'lostreturn',
504         }
505     );
506
507     return $rule ? $rule->rule_value : 'refund';
508 }
509
510 =head3 article_requestable_rules
511
512     Return rules that allow article requests, optionally filtered by
513     patron categorycode.
514
515     Use with care; see guess_article_requestable_itemtypes.
516
517 =cut
518
519 sub article_requestable_rules {
520     my ( $class, $params ) = @_;
521     my $category = $params->{categorycode};
522
523     return if !C4::Context->preference('ArticleRequests');
524     return $class->search({
525         $category ? ( categorycode => [ $category, undef ] ) : (),
526         rule_name => 'article_requests',
527         rule_value => { '!=' => 'no' },
528     });
529 }
530
531 =head3 guess_article_requestable_itemtypes
532
533     Return item types in a hashref that are likely possible to be
534     'article requested'. Constructed by an intelligent guess in the
535     issuing rules (see article_requestable_rules).
536
537     Note: pref ArticleRequestsLinkControl overrides the algorithm.
538
539     Optional parameters: categorycode.
540
541     Note: the routine is used in opac-search to obtain a reasonable
542     estimate within performance borders (not looking at all items but
543     just using default itemtype). Also we are not looking at the
544     branchcode here, since home or holding branch of the item is
545     leading and branch may be unknown too (anonymous opac session).
546
547 =cut
548
549 sub guess_article_requestable_itemtypes {
550     my ( $class, $params ) = @_;
551     my $category = $params->{categorycode};
552     return {} if !C4::Context->preference('ArticleRequests');
553     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
554
555     my $cache = Koha::Caches->get_instance;
556     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
557     my $key = $category || '*';
558     return $last_article_requestable_guesses->{$key}
559         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
560
561     my $res = {};
562     my $rules = $class->article_requestable_rules({
563         $category ? ( categorycode => $category ) : (),
564     });
565     return $res if !$rules;
566     foreach my $rule ( $rules->as_list ) {
567         $res->{ $rule->itemtype // '*' } = 1;
568     }
569     $last_article_requestable_guesses->{$key} = $res;
570     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
571     return $res;
572 }
573
574 =head3 get_daysmode_effective_value
575
576 Return the value for daysmode defined in the circulation rules.
577 If not defined (or empty string), the value of the system preference useDaysMode is returned
578
579 =cut
580
581 sub get_effective_daysmode {
582     my ( $class, $params ) = @_;
583
584     my $categorycode     = $params->{categorycode};
585     my $itemtype         = $params->{itemtype};
586     my $branchcode       = $params->{branchcode};
587
588     my $daysmode_rule = $class->get_effective_rule(
589         {
590             categorycode => $categorycode,
591             itemtype     => $itemtype,
592             branchcode   => $branchcode,
593             rule_name    => 'daysmode',
594         }
595     );
596
597     return ( defined($daysmode_rule)
598           and $daysmode_rule->rule_value ne '' )
599       ? $daysmode_rule->rule_value
600       : C4::Context->preference('useDaysMode');
601
602 }
603
604
605 =head3 type
606
607 =cut
608
609 sub _type {
610     return 'CirculationRule';
611 }
612
613 =head3 object_class
614
615 =cut
616
617 sub object_class {
618     return 'Koha::CirculationRule';
619 }
620
621 1;