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