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