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