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