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