6c36ba3b40c26e0f961c538be28c62636155e2a8
[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 under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 3 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Koha; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
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     lengthunit => {
113         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
114     },
115     maxissueqty => {
116         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
117     },
118     maxonsiteissueqty => {
119         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
120     },
121     maxsuspensiondays => {
122         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
123     },
124     no_auto_renewal_after => {
125         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
126     },
127     no_auto_renewal_after_hard_limit => {
128         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
129     },
130     norenewalbefore => {
131         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
132     },
133     onshelfholds => {
134         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
135     },
136     opacitemholds => {
137         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
138     },
139     overduefinescap => {
140         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
141     },
142     renewalperiod => {
143         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
144     },
145     renewalsallowed => {
146         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
147     },
148     rentaldiscount => {
149         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
150     },
151     reservesallowed => {
152         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
153     },
154     suspension_chargeperiod => {
155         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
156     },
157     note => { # This is not really a rule. Maybe we will want to separate this later.
158         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
159     },
160     # Not included (deprecated?):
161     #   * accountsent
162     #   * reservecharge
163     #   * restrictedtype
164 };
165
166 sub rule_kinds {
167     return $RULE_KINDS;
168 }
169
170 =head3 get_effective_rule
171
172 =cut
173
174 sub get_effective_rule {
175     my ( $self, $params ) = @_;
176
177     $params->{categorycode} //= undef;
178     $params->{branchcode}   //= undef;
179     $params->{itemtype}     //= undef;
180
181     my $rule_name    = $params->{rule_name};
182     my $categorycode = $params->{categorycode};
183     my $itemtype     = $params->{itemtype};
184     my $branchcode   = $params->{branchcode};
185
186     Koha::Exceptions::MissingParameter->throw(
187         "Required parameter 'rule_name' missing")
188       unless $rule_name;
189
190     for my $v ( $branchcode, $categorycode, $itemtype ) {
191         $v = undef if $v and $v eq '*';
192     }
193
194     my $order_by = $params->{order_by}
195       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
196
197     my $search_params;
198     $search_params->{rule_name} = $rule_name;
199
200     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
201     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
202     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
203
204     my $rule = $self->search(
205         $search_params,
206         {
207             order_by => $order_by,
208             rows => 1,
209         }
210     )->single;
211
212     return $rule;
213 }
214
215 =head3 get_effective_rules
216
217 =cut
218
219 sub get_effective_rules {
220     my ( $self, $params ) = @_;
221
222     my $rules        = $params->{rules};
223     my $categorycode = $params->{categorycode};
224     my $itemtype     = $params->{itemtype};
225     my $branchcode   = $params->{branchcode};
226
227     my $r;
228     foreach my $rule (@$rules) {
229         my $effective_rule = $self->get_effective_rule(
230             {
231                 rule_name    => $rule,
232                 categorycode => $categorycode,
233                 itemtype     => $itemtype,
234                 branchcode   => $branchcode,
235             }
236         );
237
238         $r->{$rule} = $effective_rule->rule_value if $effective_rule;
239     }
240
241     return $r;
242 }
243
244 =head3 set_rule
245
246 =cut
247
248 sub set_rule {
249     my ( $self, $params ) = @_;
250
251     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
252         Koha::Exceptions::MissingParameter->throw(
253             "Required parameter '$mandatory_parameter' missing")
254           unless exists $params->{$mandatory_parameter};
255     }
256
257     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
258     Koha::Exceptions::MissingParameter->throw(
259         "set_rule given unknown rule '$params->{rule_name}'!")
260         unless defined $kind_info;
261
262     # Enforce scope; a rule should be set for its defined scope, no more, no less.
263     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
264         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
265             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
266                 unless exists $params->{$scope_level};
267         } else {
268             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
269                 if exists $params->{$scope_level};
270         }
271     }
272
273     my $branchcode   = $params->{branchcode};
274     my $categorycode = $params->{categorycode};
275     my $itemtype     = $params->{itemtype};
276     my $rule_name    = $params->{rule_name};
277     my $rule_value   = $params->{rule_value};
278
279     for my $v ( $branchcode, $categorycode, $itemtype ) {
280         $v = undef if $v and $v eq '*';
281     }
282     my $rule = $self->search(
283         {
284             rule_name    => $rule_name,
285             branchcode   => $branchcode,
286             categorycode => $categorycode,
287             itemtype     => $itemtype,
288         }
289     )->next();
290
291     if ($rule) {
292         if ( defined $rule_value ) {
293             $rule->rule_value($rule_value);
294             $rule->update();
295         }
296         else {
297             $rule->delete();
298         }
299     }
300     else {
301         if ( defined $rule_value ) {
302             $rule = Koha::CirculationRule->new(
303                 {
304                     branchcode   => $branchcode,
305                     categorycode => $categorycode,
306                     itemtype     => $itemtype,
307                     rule_name    => $rule_name,
308                     rule_value   => $rule_value,
309                 }
310             );
311             $rule->store();
312         }
313     }
314
315     return $rule;
316 }
317
318 =head3 set_rules
319
320 =cut
321
322 sub set_rules {
323     my ( $self, $params ) = @_;
324
325     my %set_params;
326     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
327     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
328     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
329     my $rules        = $params->{rules};
330
331     my $rule_objects = [];
332     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
333         my $rule_object = Koha::CirculationRules->set_rule(
334             {
335                 %set_params,
336                 rule_name    => $rule_name,
337                 rule_value   => $rule_value,
338             }
339         );
340         push( @$rule_objects, $rule_object );
341     }
342
343     return $rule_objects;
344 }
345
346 =head3 delete
347
348 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
349
350 =cut
351
352 sub delete {
353     my ( $self ) = @_;
354
355     while ( my $rule = $self->next ){
356         $rule->delete;
357     }
358 }
359
360 =head3 clone
361
362 Clone a set of circulation rules to another branch
363
364 =cut
365
366 sub clone {
367     my ( $self, $to_branch ) = @_;
368
369     while ( my $rule = $self->next ){
370         $rule->clone($to_branch);
371     }
372 }
373
374 =head3 get_opacitemholds_policy
375
376 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
377
378 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
379 and the "Item level holds" (opacitemholds).
380 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
381
382 =cut
383
384 sub get_opacitemholds_policy {
385     my ( $class, $params ) = @_;
386
387     my $item   = $params->{item};
388     my $patron = $params->{patron};
389
390     return unless $item or $patron;
391
392     my $rule = Koha::CirculationRules->get_effective_rule(
393         {
394             categorycode => $patron->categorycode,
395             itemtype     => $item->effective_itemtype,
396             branchcode   => $item->homebranch,
397             rule_name    => 'opacitemholds',
398         }
399     );
400
401     return $rule ? $rule->rule_value : undef;
402 }
403
404 =head3 get_onshelfholds_policy
405
406     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
407
408 =cut
409
410 sub get_onshelfholds_policy {
411     my ( $class, $params ) = @_;
412     my $item = $params->{item};
413     my $itemtype = $item->effective_itemtype;
414     my $patron = $params->{patron};
415     my $rule = Koha::CirculationRules->get_effective_rule(
416         {
417             categorycode => ( $patron ? $patron->categorycode : undef ),
418             itemtype     => $itemtype,
419             branchcode   => $item->holdingbranch,
420             rule_name    => 'onshelfholds',
421         }
422     );
423     return $rule ? $rule->rule_value : 0;
424 }
425
426 =head3 article_requestable_rules
427
428     Return rules that allow article requests, optionally filtered by
429     patron categorycode.
430
431     Use with care; see guess_article_requestable_itemtypes.
432
433 =cut
434
435 sub article_requestable_rules {
436     my ( $class, $params ) = @_;
437     my $category = $params->{categorycode};
438
439     return if !C4::Context->preference('ArticleRequests');
440     return $class->search({
441         $category ? ( categorycode => [ $category, undef ] ) : (),
442         rule_name => 'article_requests',
443         rule_value => { '!=' => 'no' },
444     });
445 }
446
447 =head3 guess_article_requestable_itemtypes
448
449     Return item types in a hashref that are likely possible to be
450     'article requested'. Constructed by an intelligent guess in the
451     issuing rules (see article_requestable_rules).
452
453     Note: pref ArticleRequestsLinkControl overrides the algorithm.
454
455     Optional parameters: categorycode.
456
457     Note: the routine is used in opac-search to obtain a reasonable
458     estimate within performance borders (not looking at all items but
459     just using default itemtype). Also we are not looking at the
460     branchcode here, since home or holding branch of the item is
461     leading and branch may be unknown too (anonymous opac session).
462
463 =cut
464
465 sub guess_article_requestable_itemtypes {
466     my ( $class, $params ) = @_;
467     my $category = $params->{categorycode};
468     return {} if !C4::Context->preference('ArticleRequests');
469     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
470
471     my $cache = Koha::Caches->get_instance;
472     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
473     my $key = $category || '*';
474     return $last_article_requestable_guesses->{$key}
475         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
476
477     my $res = {};
478     my $rules = $class->article_requestable_rules({
479         $category ? ( categorycode => $category ) : (),
480     });
481     return $res if !$rules;
482     foreach my $rule ( $rules->as_list ) {
483         $res->{ $rule->itemtype // '*' } = 1;
484     }
485     $last_article_requestable_guesses->{$key} = $res;
486     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
487     return $res;
488 }
489
490
491 =head3 type
492
493 =cut
494
495 sub _type {
496     return 'CirculationRule';
497 }
498
499 =head3 object_class
500
501 =cut
502
503 sub object_class {
504     return 'Koha::CirculationRule';
505 }
506
507 1;