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