Bug 23091: Add handling for new lostreturn rules
[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     # 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 defined $rule_value && $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 $lostrefund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
437
438 Return values are:
439
440 =over 2
441
442 =item 0 - Do not refund
443 =item refund - Refund the lost item charge
444 =item restore - Refund the lost item charge and restore the original overdue fine
445 =item charge - Refund the lost item charge and charge a new overdue fine
446
447 =back
448
449 =cut
450
451 sub get_lostreturn_policy {
452     my ( $class, $params ) = @_;
453
454     my $item   = $params->{item};
455
456     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
457     my $behaviour_mapping = {
458         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
459         ItemHomeBranch    => $item->homebranch,
460         ItemHoldingBranch => $item->holdingbranch
461     };
462
463     my $branch = $behaviour_mapping->{ $behaviour };
464
465     my $rule = Koha::CirculationRules->get_effective_rule(
466         {
467             branchcode => $branch,
468             rule_name  => 'lostreturn',
469         }
470     );
471
472     return $rule ? $rule->rule_value : 'refund';
473 }
474
475 =head3 article_requestable_rules
476
477     Return rules that allow article requests, optionally filtered by
478     patron categorycode.
479
480     Use with care; see guess_article_requestable_itemtypes.
481
482 =cut
483
484 sub article_requestable_rules {
485     my ( $class, $params ) = @_;
486     my $category = $params->{categorycode};
487
488     return if !C4::Context->preference('ArticleRequests');
489     return $class->search({
490         $category ? ( categorycode => [ $category, undef ] ) : (),
491         rule_name => 'article_requests',
492         rule_value => { '!=' => 'no' },
493     });
494 }
495
496 =head3 guess_article_requestable_itemtypes
497
498     Return item types in a hashref that are likely possible to be
499     'article requested'. Constructed by an intelligent guess in the
500     issuing rules (see article_requestable_rules).
501
502     Note: pref ArticleRequestsLinkControl overrides the algorithm.
503
504     Optional parameters: categorycode.
505
506     Note: the routine is used in opac-search to obtain a reasonable
507     estimate within performance borders (not looking at all items but
508     just using default itemtype). Also we are not looking at the
509     branchcode here, since home or holding branch of the item is
510     leading and branch may be unknown too (anonymous opac session).
511
512 =cut
513
514 sub guess_article_requestable_itemtypes {
515     my ( $class, $params ) = @_;
516     my $category = $params->{categorycode};
517     return {} if !C4::Context->preference('ArticleRequests');
518     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
519
520     my $cache = Koha::Caches->get_instance;
521     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
522     my $key = $category || '*';
523     return $last_article_requestable_guesses->{$key}
524         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
525
526     my $res = {};
527     my $rules = $class->article_requestable_rules({
528         $category ? ( categorycode => $category ) : (),
529     });
530     return $res if !$rules;
531     foreach my $rule ( $rules->as_list ) {
532         $res->{ $rule->itemtype // '*' } = 1;
533     }
534     $last_article_requestable_guesses->{$key} = $res;
535     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
536     return $res;
537 }
538
539 =head3 get_daysmode_effective_value
540
541 Return the value for daysmode defined in the circulation rules.
542 If not defined (or empty string), the value of the system preference useDaysMode is returned
543
544 =cut
545
546 sub get_effective_daysmode {
547     my ( $class, $params ) = @_;
548
549     my $categorycode     = $params->{categorycode};
550     my $itemtype         = $params->{itemtype};
551     my $branchcode       = $params->{branchcode};
552
553     my $daysmode_rule = $class->get_effective_rule(
554         {
555             categorycode => $categorycode,
556             itemtype     => $itemtype,
557             branchcode   => $branchcode,
558             rule_name    => 'daysmode',
559         }
560     );
561
562     return ( defined($daysmode_rule)
563           and $daysmode_rule->rule_value ne '' )
564       ? $daysmode_rule->rule_value
565       : C4::Context->preference('useDaysMode');
566
567 }
568
569
570 =head3 type
571
572 =cut
573
574 sub _type {
575     return 'CirculationRule';
576 }
577
578 =head3 object_class
579
580 =cut
581
582 sub object_class {
583     return 'Koha::CirculationRule';
584 }
585
586 1;