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