Bug 20256: DBIC schema
[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 use Koha::Caches;
26 use Koha::Cache::Memory::Lite;
27
28 use base qw(Koha::Objects);
29
30 use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';
31
32 =head1 NAME
33
34 Koha::CirculationRules - Koha CirculationRule Object set class
35
36 =head1 API
37
38 =head2 Class Methods
39
40 =cut
41
42 =head3 rule_kinds
43
44 This structure describes the possible rules that may be set, and what scopes they can be set at.
45
46 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.
47
48 =cut
49
50 our $RULE_KINDS = {
51     lostreturn => {
52         scope => [ 'branchcode' ],
53     },
54     processingreturn => {
55         scope => [ 'branchcode' ],
56     },
57     patron_maxissueqty => {
58         scope => [ 'branchcode', 'categorycode' ],
59     },
60     patron_maxonsiteissueqty => {
61         scope => [ 'branchcode', 'categorycode' ],
62     },
63     max_holds => {
64         scope => [ 'branchcode', 'categorycode' ],
65     },
66
67     holdallowed => {
68         scope => [ 'branchcode', 'itemtype' ],
69         can_be_blank => 0,
70     },
71     hold_fulfillment_policy => {
72         scope => [ 'branchcode', 'itemtype' ],
73         can_be_blank => 0,
74     },
75     returnbranch => {
76         scope => [ 'branchcode', 'itemtype' ],
77         can_be_blank => 0,
78     },
79
80     article_requests => {
81         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
82     },
83     article_request_fee => {
84         scope => [ 'branchcode', 'categorycode' ],
85     },
86     open_article_requests_limit => {
87         scope => [ 'branchcode', 'categorycode' ],
88     },
89
90     auto_renew => {
91         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
92     },
93     cap_fine_to_replacement_price => {
94         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
95     },
96     chargeperiod => {
97         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
98     },
99     chargeperiod_charge_at => {
100         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
101     },
102     fine => {
103         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
104     },
105     finedays => {
106         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
107     },
108     firstremind => {
109         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
110     },
111     hardduedate => {
112         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
113     },
114     hardduedatecompare => {
115         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
116     },
117     waiting_hold_cancellation => {
118         scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
119         can_be_blank => 0,
120     },
121     holds_per_day => {
122         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
123     },
124     holds_per_record => {
125         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
126     },
127     issuelength => {
128         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
129     },
130     daysmode => {
131         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
132     },
133     lengthunit => {
134         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
135     },
136     maxissueqty => {
137         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
138     },
139     maxonsiteissueqty => {
140         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
141     },
142     maxsuspensiondays => {
143         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
144     },
145     no_auto_renewal_after => {
146         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
147     },
148     no_auto_renewal_after_hard_limit => {
149         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
150     },
151     norenewalbefore => {
152         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
153     },
154     onshelfholds => {
155         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
156     },
157     opacitemholds => {
158         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
159     },
160     overduefinescap => {
161         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
162     },
163     renewalperiod => {
164         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
165     },
166     renewalsallowed => {
167         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
168     },
169     unseen_renewals_allowed => {
170         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
171     },
172     rentaldiscount => {
173         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
174         can_be_blank => 0,
175     },
176     reservesallowed => {
177         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
178     },
179     suspension_chargeperiod => {
180         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
181     },
182     note => { # This is not really a rule. Maybe we will want to separate this later.
183         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
184     },
185     decreaseloanholds => {
186         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
187     },
188     recalls_allowed => {
189         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
190     },
191     recalls_per_record => {
192         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
193     },
194     on_shelf_recalls => {
195         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
196     },
197     recall_due_date_interval => {
198         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
199     },
200     recall_overdue_fine => {
201         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
202     },
203     recall_shelf_time => {
204         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
205     },
206     # Not included (deprecated?):
207     #   * accountsent
208     #   * reservecharge
209     #   * restrictedtype
210 };
211
212 sub rule_kinds {
213     return $RULE_KINDS;
214 }
215
216 =head3 get_effective_rule
217
218   my $effective_rule = Koha::CirculationRules->get_effective_rule(
219     {
220         rule_name    => $name,
221         categorycode => $categorycode,
222         itemtype     => $itemtype,
223         branchcode   => $branchcode
224     }
225   );
226
227 Return the effective rule object for the rule associated with the criteria passed.
228
229
230 =cut
231
232 sub get_effective_rule {
233     my ( $self, $params ) = @_;
234
235     $params->{categorycode} //= undef;
236     $params->{branchcode}   //= undef;
237     $params->{itemtype}     //= undef;
238
239     my $rule_name    = $params->{rule_name};
240     my $categorycode = $params->{categorycode};
241     my $itemtype     = $params->{itemtype};
242     my $branchcode   = $params->{branchcode};
243
244     Koha::Exceptions::MissingParameter->throw(
245         "Required parameter 'rule_name' missing")
246       unless $rule_name;
247
248     for my $v ( $branchcode, $categorycode, $itemtype ) {
249         $v = undef if $v and $v eq '*';
250     }
251
252     my $order_by = $params->{order_by}
253       // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };
254
255     my $search_params;
256     $search_params->{rule_name} = $rule_name;
257
258     $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
259     $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype, undef ] : undef;
260     $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;
261
262     my $rule = $self->search(
263         $search_params,
264         {
265             order_by => $order_by,
266             rows => 1,
267         }
268     )->single;
269
270     return $rule;
271 }
272
273 =head3 get_effective_rule_value
274
275   my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
276     {
277         rule_name    => $name,
278         categorycode => $categorycode,
279         itemtype     => $itemtype,
280         branchcode   => $branchcode
281     }
282   );
283
284 Return the effective value for the rule associated with the criteria passed.
285
286 This is a cached method so should be used in preference to get_effective_rule where possible
287 to aid performance.
288
289 =cut
290
291 sub get_effective_rule_value {
292     my ( $self, $params ) = @_;
293
294     my $rule_name    = $params->{rule_name};
295     my $categorycode = $params->{categorycode};
296     my $itemtype     = $params->{itemtype};
297     my $branchcode   = $params->{branchcode};
298
299     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
300     my $cache_key = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
301       $categorycode // q{}, $branchcode // q{}, $itemtype // q{};
302
303     my $cached       = $memory_cache->get_from_cache($cache_key);
304     return $cached if $cached;
305
306     my $rule = $self->get_effective_rule($params);
307
308     my $value= $rule ? $rule->rule_value : undef;
309     $memory_cache->set_in_cache( $cache_key, $value );
310     return $value;
311 }
312
313 =head3 get_effective_rules
314
315 =cut
316
317 sub get_effective_rules {
318     my ( $self, $params ) = @_;
319
320     my $rules        = $params->{rules};
321     my $categorycode = $params->{categorycode};
322     my $itemtype     = $params->{itemtype};
323     my $branchcode   = $params->{branchcode};
324
325     my $r;
326     foreach my $rule (@$rules) {
327         my $effective_rule = $self->get_effective_rule_value(
328             {
329                 rule_name    => $rule,
330                 categorycode => $categorycode,
331                 itemtype     => $itemtype,
332                 branchcode   => $branchcode,
333             }
334         );
335
336         $r->{$rule} = $effective_rule if defined $effective_rule;
337     }
338
339     return $r;
340 }
341
342 =head3 set_rule
343
344 =cut
345
346 sub set_rule {
347     my ( $self, $params ) = @_;
348
349     for my $mandatory_parameter (qw( rule_name rule_value ) ) {
350         Koha::Exceptions::MissingParameter->throw(
351             "Required parameter '$mandatory_parameter' missing")
352           unless exists $params->{$mandatory_parameter};
353     }
354
355     my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
356     Koha::Exceptions::MissingParameter->throw(
357         "set_rule given unknown rule '$params->{rule_name}'!")
358         unless defined $kind_info;
359
360     # Enforce scope; a rule should be set for its defined scope, no more, no less.
361     foreach my $scope_level ( qw( branchcode categorycode itemtype ) ) {
362         if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
363             croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
364                 unless exists $params->{$scope_level};
365         } else {
366             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
367                 if exists $params->{$scope_level};
368         }
369     }
370
371     my $branchcode   = $params->{branchcode};
372     my $categorycode = $params->{categorycode};
373     my $itemtype     = $params->{itemtype};
374     my $rule_name    = $params->{rule_name};
375     my $rule_value   = $params->{rule_value};
376     my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
377     $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
378
379     for my $v ( $branchcode, $categorycode, $itemtype ) {
380         $v = undef if $v and $v eq '*';
381     }
382     my $rule = $self->search(
383         {
384             rule_name    => $rule_name,
385             branchcode   => $branchcode,
386             categorycode => $categorycode,
387             itemtype     => $itemtype,
388         }
389     )->next();
390
391     if ($rule) {
392         if ( defined $rule_value ) {
393             $rule->rule_value($rule_value);
394             $rule->update();
395         }
396         else {
397             $rule->delete();
398         }
399     }
400     else {
401         if ( defined $rule_value ) {
402             $rule = Koha::CirculationRule->new(
403                 {
404                     branchcode   => $branchcode,
405                     categorycode => $categorycode,
406                     itemtype     => $itemtype,
407                     rule_name    => $rule_name,
408                     rule_value   => $rule_value,
409                 }
410             );
411             $rule->store();
412         }
413     }
414
415     my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
416     for my $k ( $memory_cache->all_keys ) {
417         $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
418     }
419
420     return $rule;
421 }
422
423 =head3 set_rules
424
425 =cut
426
427 sub set_rules {
428     my ( $self, $params ) = @_;
429
430     my %set_params;
431     $set_params{branchcode} = $params->{branchcode} if exists $params->{branchcode};
432     $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
433     $set_params{itemtype} = $params->{itemtype} if exists $params->{itemtype};
434     my $rules        = $params->{rules};
435
436     my $rule_objects = [];
437     while ( my ( $rule_name, $rule_value ) = each %$rules ) {
438         my $rule_object = Koha::CirculationRules->set_rule(
439             {
440                 %set_params,
441                 rule_name    => $rule_name,
442                 rule_value   => $rule_value,
443             }
444         );
445         push( @$rule_objects, $rule_object );
446     }
447
448     return $rule_objects;
449 }
450
451 =head3 delete
452
453 Delete a set of circulation rules, needed for cleaning up when deleting issuingrules
454
455 =cut
456
457 sub delete {
458     my ( $self ) = @_;
459
460     while ( my $rule = $self->next ){
461         $rule->delete;
462     }
463 }
464
465 =head3 clone
466
467 Clone a set of circulation rules to another branch
468
469 =cut
470
471 sub clone {
472     my ( $self, $to_branch ) = @_;
473
474     while ( my $rule = $self->next ){
475         $rule->clone($to_branch);
476     }
477 }
478
479 =head2 get_return_branch_policy
480
481   my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);
482
483 Returns the branch to use for returning the item based on the
484 item type, and a branch selected via CircControlReturnsBranch.
485
486 The return value is the branch to which to return the item. Possible values:
487   noreturn: do not return, let item remain where checked in (floating collections)
488   homebranch: return to item's home branch
489   holdingbranch: return to issuer branch
490
491 This searches branchitemrules in the following order:
492   * Same branchcode and itemtype
493   * Same branchcode, itemtype '*'
494   * branchcode '*', same itemtype
495   * branchcode '*' and itemtype '*'
496
497 =cut
498
499 sub get_return_branch_policy {
500     my ( $self, $item ) = @_;
501
502     my $pref = C4::Context->preference('CircControlReturnsBranch');
503
504     my $branchcode =
505         $pref eq 'ItemHomeLibrary'     ? $item->homebranch
506       : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
507       : $pref eq 'CheckInLibrary'      ? C4::Context->userenv
508           ? C4::Context->userenv->{branch}
509           : $item->homebranch
510       : $item->homebranch;
511
512     my $itemtype = $item->effective_itemtype;
513
514     my $rule = Koha::CirculationRules->get_effective_rule(
515         {
516             rule_name  => 'returnbranch',
517             itemtype   => $itemtype,
518             branchcode => $branchcode,
519         }
520     );
521
522     return $rule ? $rule->rule_value : 'homebranch';
523 }
524
525
526 =head3 get_opacitemholds_policy
527
528 my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );
529
530 Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
531 and the "Item level holds" (opacitemholds).
532 Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force
533
534 =cut
535
536 sub get_opacitemholds_policy {
537     my ( $class, $params ) = @_;
538
539     my $item   = $params->{item};
540     my $patron = $params->{patron};
541
542     return unless $item or $patron;
543
544     my $rule = Koha::CirculationRules->get_effective_rule(
545         {
546             categorycode => $patron->categorycode,
547             itemtype     => $item->effective_itemtype,
548             branchcode   => $item->homebranch,
549             rule_name    => 'opacitemholds',
550         }
551     );
552
553     return $rule ? $rule->rule_value : undef;
554 }
555
556 =head3 get_onshelfholds_policy
557
558     my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });
559
560 =cut
561
562 sub get_onshelfholds_policy {
563     my ( $class, $params ) = @_;
564     my $item = $params->{item};
565     my $itemtype = $item->effective_itemtype;
566     my $patron = $params->{patron};
567     my $rule = Koha::CirculationRules->get_effective_rule(
568         {
569             categorycode => ( $patron ? $patron->categorycode : undef ),
570             itemtype     => $itemtype,
571             branchcode   => $item->holdingbranch,
572             rule_name    => 'onshelfholds',
573         }
574     );
575     return $rule ? $rule->rule_value : 0;
576 }
577
578 =head3 get_lostreturn_policy
579
580   my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );
581
582 lostreturn return values are:
583
584 =over 2
585
586 =item '0' - Do not refund
587
588 =item 'refund' - Refund the lost item charge
589
590 =item 'restore' - Refund the lost item charge and restore the original overdue fine
591
592 =item 'charge' - Refund the lost item charge and charge a new overdue fine
593
594 =back
595
596 processing return return values are:
597
598 =over 2
599
600 =item '0' - Do not refund
601
602 =item 'refund' - Refund the lost item processing charge
603
604 =item 'restore' - Refund the lost item processing charge and restore the original overdue fine
605
606 =item 'charge' - Refund the lost item processing charge and charge a new overdue fine
607
608 =back
609
610
611 =cut
612
613 sub get_lostreturn_policy {
614     my ( $class, $params ) = @_;
615
616     my $item   = $params->{item};
617
618     my $behaviour = C4::Context->preference( 'RefundLostOnReturnControl' ) // 'CheckinLibrary';
619     my $behaviour_mapping = {
620         CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
621         ItemHomeBranch    => $item->homebranch,
622         ItemHoldingBranch => $item->holdingbranch
623     };
624
625     my $branch = $behaviour_mapping->{ $behaviour };
626
627     my $rules = Koha::CirculationRules->get_effective_rules(
628         {
629             branchcode => $branch,
630             rules  => ['lostreturn','processingreturn']
631         }
632     );
633
634     $rules->{lostreturn} //= 'refund';
635     $rules->{processingreturn} //= 'refund';
636     return $rules;
637 }
638
639 =head3 article_requestable_rules
640
641     Return rules that allow article requests, optionally filtered by
642     patron categorycode.
643
644     Use with care; see guess_article_requestable_itemtypes.
645
646 =cut
647
648 sub article_requestable_rules {
649     my ( $class, $params ) = @_;
650     my $category = $params->{categorycode};
651
652     return if !C4::Context->preference('ArticleRequests');
653     return $class->search({
654         $category ? ( categorycode => [ $category, undef ] ) : (),
655         rule_name => 'article_requests',
656         rule_value => { '!=' => 'no' },
657     });
658 }
659
660 =head3 guess_article_requestable_itemtypes
661
662     Return item types in a hashref that are likely possible to be
663     'article requested'. Constructed by an intelligent guess in the
664     issuing rules (see article_requestable_rules).
665
666     Note: pref ArticleRequestsLinkControl overrides the algorithm.
667
668     Optional parameters: categorycode.
669
670     Note: the routine is used in opac-search to obtain a reasonable
671     estimate within performance borders (not looking at all items but
672     just using default itemtype). Also we are not looking at the
673     branchcode here, since home or holding branch of the item is
674     leading and branch may be unknown too (anonymous opac session).
675
676 =cut
677
678 sub guess_article_requestable_itemtypes {
679     my ( $class, $params ) = @_;
680     my $category = $params->{categorycode};
681     return {} if !C4::Context->preference('ArticleRequests');
682     return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';
683
684     my $cache = Koha::Caches->get_instance;
685     my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
686     my $key = $category || '*';
687     return $last_article_requestable_guesses->{$key}
688         if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};
689
690     my $res = {};
691     my $rules = $class->article_requestable_rules({
692         $category ? ( categorycode => $category ) : (),
693     });
694     return $res if !$rules;
695     foreach my $rule ( $rules->as_list ) {
696         $res->{ $rule->itemtype // '*' } = 1;
697     }
698     $last_article_requestable_guesses->{$key} = $res;
699     $cache->set_in_cache(GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses);
700     return $res;
701 }
702
703 =head3 get_effective_daysmode
704
705 Return the value for daysmode defined in the circulation rules.
706 If not defined (or empty string), the value of the system preference useDaysMode is returned
707
708 =cut
709
710 sub get_effective_daysmode {
711     my ( $class, $params ) = @_;
712
713     my $categorycode     = $params->{categorycode};
714     my $itemtype         = $params->{itemtype};
715     my $branchcode       = $params->{branchcode};
716
717     my $daysmode_rule = $class->get_effective_rule(
718         {
719             categorycode => $categorycode,
720             itemtype     => $itemtype,
721             branchcode   => $branchcode,
722             rule_name    => 'daysmode',
723         }
724     );
725
726     return ( defined($daysmode_rule)
727           and $daysmode_rule->rule_value ne '' )
728       ? $daysmode_rule->rule_value
729       : C4::Context->preference('useDaysMode');
730
731 }
732
733
734 =head3 type
735
736 =cut
737
738 sub _type {
739     return 'CirculationRule';
740 }
741
742 =head3 object_class
743
744 =cut
745
746 sub object_class {
747     return 'Koha::CirculationRule';
748 }
749
750 1;