From 491e0ed88aabf1d5091a27f1a9e6540c1c08b989 Mon Sep 17 00:00:00 2001
From: Thibaud Guillot <thibaud.guillot@biblibre.com>
Date: Mon, 18 Mar 2024 14:40:21 +0100
Subject: [PATCH] Bug 36271: Add circulation rules for booking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Welcome to 3 new circulation rules :)

- Bookings allowed (total) / bookings_allowed_total
- Bookings per item (total) / bookings_per_item
- Bookings period length (day) / bookings_period_length

Test plan:
1) Apply this patch
2) Set values for these new rules
3) Define a total to 2, 1 per item with 2 days for
   example for booking period.
4) Book an item and retry on the same item, normally an error message is
   showing in the pop up.
6) Book another item, and again another one, on the third an error
   message is showing (always in pop up)
7) You can also try to test with a longer period than bookings period
  length rule, an another error will be displayed.

Sponsored by: Association de Gestion des Å’uvres Sociales d'Inria (AGOS)
---
 Koha/Booking.pm                               | 100 ++++++++++++++++++
 Koha/CirculationRules.pm                      |   9 ++
 Koha/Exceptions/Booking.pm                    |   4 +
 Koha/REST/V1/Bookings.pm                      |  18 ++++
 admin/smart-rules.pl                          |   9 ++
 .../prog/en/modules/admin/smart-rules.tt      |  35 +++++-
 .../prog/js/modals/place_booking.js           |   8 +-
 7 files changed, 177 insertions(+), 6 deletions(-)

diff --git a/Koha/Booking.pm b/Koha/Booking.pm
index f1004ae1f73..a2945c57c0f 100644
--- a/Koha/Booking.pm
+++ b/Koha/Booking.pm
@@ -21,8 +21,15 @@ use Modern::Perl;
 
 use Koha::Exceptions::Booking;
 use Koha::DateUtils qw( dt_from_string );
+use Koha::Bookings;
+use Koha::CirculationRules;
+use Koha::Cache::Memory::Lite;
+
+use C4::Circulation;
+use C4::Biblio;
 
 use base qw(Koha::Object);
+use List::Util qw(min);
 
 =head1 NAME
 
@@ -32,6 +39,96 @@ Koha::Booking - Koha Booking object class
 
 =head2 Class methods
 
+=head2 can_be_booked_in_advance
+
+  $canBeBooked = &can_be_booked_in_advance($patron, $item, $branchcode)
+  if ($canBeBooked->{status} eq 'OK') { #We can booked this Item in advance! }
+
+@RETURNS { status => OK },              if the Item can be booked.
+         { status => tooManyBookings, limit => $limit, rule => $rule }, if the borrower has exceeded their maximum booking amount.
+         { status => tooLongBookingPeriod, limit => $limit }, if the borrower has exceeded their maximum booking period.
+         { status => bookingPeriodNotValid }, if booking period is not valid (undef or equal to 0).
+=cut
+
+sub can_be_booked_in_advance {
+    my ( $self, $params ) = @_;
+    my $patron = $self->patron;
+    my $item = $self->item;
+
+    my $dbh = C4::Context->dbh;
+
+    my $borrower = $patron->unblessed;
+
+    if ( C4::Biblio->GetMarcFromKohaField('biblioitems.agerestriction') ) {
+        my $biblio = $item->biblio;
+
+        # Check for the age restriction
+        my ( $ageRestriction, $daysToAgeRestriction ) =
+            C4::Circulation::GetAgeRestriction( $biblio->biblioitem->agerestriction, $borrower );
+        return { status => 'ageRestricted' } if $daysToAgeRestriction && $daysToAgeRestriction > 0;
+    }
+
+    # By default for now, control branch is the item homebranch
+    my $bookings_control_branch = $item->homebranch;
+
+    # we retrieve rights
+    my $rights = Koha::CirculationRules->get_effective_rules(
+        {
+            categorycode => $borrower->{'categorycode'},
+            itemtype     => $item->effective_itemtype,
+            branchcode   => $bookings_control_branch,
+            rules        =>
+                [ 'bookings_allowed_total', 'bookings_per_item', 'bookings_period_length' ]
+        }
+    );
+
+    my $bookings_allowed_total = $rights->{bookings_allowed_total} || undef;
+    my $bookings_per_item      = $rights->{bookings_per_item} || undef;
+    my $bookings_period_length = $rights->{bookings_period_length} || 0;
+
+    return { status => 'noBookingsAllowed' } if defined($bookings_allowed_total) && $bookings_allowed_total == 0;
+
+    my $bookings_per_item_count = Koha::Bookings->search( { patron_id => $patron->borrowernumber, item_id => $item->itemnumber } )->count();
+    return { status => 'tooManyBookings', limit => $bookings_per_item, rule => 'bookings_per_item' } if defined($bookings_per_item) && $bookings_per_item <= $bookings_per_item_count;
+
+
+    my $querycount;
+    if (C4::Context->preference('item-level_itypes')) {
+        $querycount = q{
+            SELECT count(*) AS count
+                FROM bookings AS b
+                LEFT JOIN items AS i ON (b.item_id=i.itemnumber)
+                WHERE b.patron_id = ?
+                AND i.itype = ?
+                };
+    } else {
+        $querycount = q{
+            SELECT count(*) AS count
+                FROM bookings AS b
+                LEFT JOIN biblioitems AS bi ON (b.biblio_id=bi.biblionumber)
+                WHERE b.patron_id = ?
+                AND bi.itemtype = ?
+                };
+    }
+
+    my $sthcount = $dbh->prepare($querycount);
+    $sthcount->execute( $patron->borrowernumber, $item->effective_itemtype );
+    my $total_bookings_count = $sthcount->fetchrow_hashref()->{count};
+
+    return { status => 'tooManyBookings', limit => $bookings_allowed_total, rule => 'bookings_allowed_total' } if defined($bookings_allowed_total) && $bookings_allowed_total <= $total_bookings_count;
+
+    my $start_date = dt_from_string( $self->start_date );
+    my $end_date   = dt_from_string( $self->end_date );
+    my $duration = $end_date->delta_days($start_date);
+
+    my $delta_days = $duration->in_units('days');
+
+    return { status => 'bookingPeriodNotValid'} if $bookings_period_length == 0;
+    return { status => 'tooLongBookingPeriod', limit => $bookings_period_length } if $delta_days > $bookings_period_length;
+
+    return { status => 'OK' };
+}
+
 =head3 biblio
 
 Returns the related Koha::Biblio object for this booking
@@ -139,6 +236,9 @@ sub store {
 
             # FIXME: We should be able to combine the above two functions into one
 
+            my $canBeBooked = can_be_booked_in_advance( $self );
+            Koha::Exceptions::Booking::Rule->throw( $canBeBooked ) if $canBeBooked->{'status'} ne "OK";
+
             # Assign item at booking time
             if ( !$self->item_id ) {
                 $self->_assign_item_for_booking;
diff --git a/Koha/CirculationRules.pm b/Koha/CirculationRules.pm
index 1d074768754..642eec7c124 100644
--- a/Koha/CirculationRules.pm
+++ b/Koha/CirculationRules.pm
@@ -216,6 +216,15 @@ our $RULE_KINDS = {
     holds_pickup_period => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
     },
+    bookings_allowed_total => {
+        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+    },
+    bookings_per_item => {
+        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+    },
+    bookings_period_length => {
+        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+    },
     # Not included (deprecated?):
     #   * accountsent
     #   * reservecharge
diff --git a/Koha/Exceptions/Booking.pm b/Koha/Exceptions/Booking.pm
index 79e5b6ad8cf..bad8f4037ec 100644
--- a/Koha/Exceptions/Booking.pm
+++ b/Koha/Exceptions/Booking.pm
@@ -8,6 +8,10 @@ use Exception::Class (
         isa         => 'Koha::Exceptions::Booking',
         description => "Adding or updating the booking would result in a clash"
     },
+    'Koha::Exceptions::Booking::Rule' => {
+        isa         => 'Koha::Exceptions::Booking',
+        description => "Booking rejected by circulation rules"
+    }
 );
 
 1;
diff --git a/Koha/REST/V1/Bookings.pm b/Koha/REST/V1/Bookings.pm
index 44e381ef1b9..d96aa70ddac 100644
--- a/Koha/REST/V1/Bookings.pm
+++ b/Koha/REST/V1/Bookings.pm
@@ -95,6 +95,24 @@ sub add {
                     error => "Duplicate booking_id",
                 }
             );
+        } elsif ( blessed $_ and $_->isa('Koha::Exceptions::Booking::Rule') ) {
+            my $error_code = $_->{'message'}->{'status'};
+            my $limit = $_->{'message'}->{'limit'} // '';
+            my $rule = $_->{'message'}->{'rule'} // '';
+            my %error_strings = (
+                'noBookingsAllowed' => 'Bookings are not allowed according to circulation rules',
+                'tooManyBookings' => sprintf('Patron has reached the maximum of booking according to circulation rules : %s maximum (%s)', $limit, $rule),
+                'tooLongBookingPeriod' => sprintf('Booking period exceed booking period limit according to circulation rules : %s day(s)', $limit),
+                'bookingPeriodNotValid' => sprintf('Booking period must be valid'),
+                'ageRestricted' =>  "Age restricted",
+            );
+
+            return $c->render(
+                status  => 403,
+                openapi => {
+                    error => $error_strings{$error_code},
+                }
+            );
         }
 
         return $c->unhandled_exception($_);
diff --git a/admin/smart-rules.pl b/admin/smart-rules.pl
index efe1b91dfb5..4299e1d43d3 100755
--- a/admin/smart-rules.pl
+++ b/admin/smart-rules.pl
@@ -117,6 +117,9 @@ if ($op eq 'cud-delete') {
                 recall_shelf_time                => undef,
                 decreaseloanholds                => undef,
                 holds_pickup_period              => undef,
+                bookings_allowed_total           => undef,
+                bookings_per_item                => undef,
+                bookings_period_length           => undef,
             }
         }
     );
@@ -302,6 +305,9 @@ elsif ( $op eq 'cud-add' ) {
     my $recall_overdue_fine           = $input->param('recall_overdue_fine');
     my $recall_shelf_time             = $input->param('recall_shelf_time');
     my $holds_pickup_period           = strip_non_numeric( scalar $input->param('holds_pickup_period') );
+    my $bookings_allowed_total        = strip_non_numeric( scalar $input->param('bookings_allowed_total') );
+    my $bookings_per_item             = strip_non_numeric( scalar $input->param('bookings_per_item') );
+    my $bookings_period_length        = $input->param('bookings_period_length') || 0;
 
     my $rules = {
         maxissueqty                      => $maxissueqty,
@@ -344,6 +350,9 @@ elsif ( $op eq 'cud-add' ) {
         recall_overdue_fine              => $recall_overdue_fine,
         recall_shelf_time                => $recall_shelf_time,
         holds_pickup_period              => $holds_pickup_period,
+        bookings_allowed_total           => $bookings_allowed_total,
+        bookings_per_item                => $bookings_per_item,
+        bookings_period_length           => $bookings_period_length,
     };
 
     Koha::CirculationRules->set_rules(
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/smart-rules.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/smart-rules.tt
index 8d0562edab8..5bfcfbc9389 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/smart-rules.tt
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/smart-rules.tt
@@ -143,6 +143,9 @@
                             <th>On shelf holds allowed</th>
                             <th>OPAC item level holds</th>
                             <th>Holds pickup period (day)</th>
+                            <th>Bookings allowed (total)</th>
+                            <th>Bookings per item (total)</th>
+                            <th>Bookings period length (day)</th>
                             [% IF Koha.Preference('ArticleRequests') %]
                             <th>Article requests</th>
                             [% END %]
@@ -204,8 +207,11 @@
                                     [% SET recall_overdue_fine = all_rules.$c.$i.recall_overdue_fine %]
                                     [% SET recall_shelf_time = all_rules.$c.$i.recall_shelf_time %]
                                     [% SET holds_pickup_period = all_rules.$c.$i.holds_pickup_period %]
+                                    [% SET bookings_allowed_total = all_rules.$c.$i.bookings_allowed_total %]
+                                    [% SET bookings_per_item = all_rules.$c.$i.bookings_per_item %]
+                                    [% SET bookings_period_length = all_rules.$c.$i.bookings_period_length %]
 
-                                    [% SET show_rule = note || maxissueqty || maxonsiteissueqty || issuelength || daysmode || lengthunit || hardduedate || hardduedatecompare || fine || chargeperiod || chargeperiod_charge_at || firstremind || overduefinescap || cap_fine_to_replacement_price || finedays || maxsuspensiondays || suspension_chargeperiod || renewalsallowed || unseenrenewalsallowed || renewalperiod || norenewalbefore || noautorenewalbefore || auto_renew || no_auto_renewal_after || no_auto_renewal_after_hard_limit || reservesallowed || holds_per_day || holds_per_record || onshelfholds || opacitemholds || article_requests || rentaldiscount || decreaseloanholds || recalls_allowed || recalls_per_record || on_shelf_recalls || recall_due_date_interval || recall_overdue_fine || recall_shelf_time || holds_pickup_period %]
+                                    [% SET show_rule = note || maxissueqty || maxonsiteissueqty || issuelength || daysmode || lengthunit || hardduedate || hardduedatecompare || fine || chargeperiod || chargeperiod_charge_at || firstremind || overduefinescap || cap_fine_to_replacement_price || finedays || maxsuspensiondays || suspension_chargeperiod || renewalsallowed || unseenrenewalsallowed || renewalperiod || norenewalbefore || noautorenewalbefore || auto_renew || no_auto_renewal_after || no_auto_renewal_after_hard_limit || reservesallowed || holds_per_day || holds_per_record || onshelfholds || opacitemholds || article_requests || rentaldiscount || decreaseloanholds || recalls_allowed || recalls_per_record || on_shelf_recalls || recall_due_date_interval || recall_overdue_fine || recall_shelf_time || holds_pickup_period || bookings_allowed_total || bookings_per_item || bookings_period_length %]
                                     [% IF show_rule %]
                                         [% SET row_count = row_count + 1 %]
                                         <tr row_countd="row_[% row_count | html %]">
@@ -372,6 +378,27 @@
                                                         [% holds_pickup_period | html %]
                                                     [% END %]
                                                 </td>
+                                                <td>
+                                                    [% IF bookings_allowed_total.defined && bookings_allowed_total != '' %]
+                                                        [% bookings_allowed_total | html %]
+                                                    [% ELSE %]
+                                                        <span>Unlimited</span>
+                                                    [% END %]
+                                                </td>
+                                                <td>
+                                                    [% IF bookings_per_item.defined && bookings_per_item != '' %]
+                                                        [% bookings_per_item | html %]
+                                                    [% ELSE %]
+                                                        <span>Unlimited</span>
+                                                    [% END %]
+                                                </td>
+                                                <td>
+                                                    [% IF bookings_period_length.defined && bookings_period_length != '' %]
+                                                        [% bookings_period_length | html %]
+                                                    [% ELSE %]
+                                                        <span>Not defined</span>
+                                                    [% END %]
+                                                </td>
                                                 [% IF Koha.Preference('ArticleRequests') %]
                                                 <td data-code="[% article_requests | html %]">
                                                     [% IF article_requests == 'no' %]
@@ -522,6 +549,9 @@
                                     </select>
                                 </td>
                                 <td><input type="text" name="holds_pickup_period" id="holds_pickup_period" size="2" /></td>
+                                <td><input type="text" name="bookings_allowed_total" id="bookings_allowed_total" size="2" /></td>
+                                <td><input type="text" name="bookings_per_item" id="bookings_per_item" size="2" /></td>
+                                <td><input type="text" name="bookings_period_length" id="bookings_period_length" size="3" /></td>
                                 [% IF Koha.Preference('ArticleRequests') %]
                                 <td>
                                     <select id="article_requests" name="article_requests">
@@ -593,6 +623,9 @@
                                   <th>On shelf holds allowed</th>
                                   <th>OPAC item level holds</th>
                                   <th>Holds pickup period (day)</th>
+                                  <th>Bookings allowed (total)</th>
+                                  <th>Bookings per item (total)</th>
+                                  <th>Bookings period length (day)</th>
                                   [% IF Koha.Preference('ArticleRequests') %]
                                   <th>Article requests</th>
                                   [% END %]
diff --git a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js
index 99b1b6ddf22..c089e612dc8 100644
--- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js
+++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js
@@ -856,11 +856,9 @@ $("#placeBookingForm").on("submit", function (e) {
         });
 
         posting.fail(function (data) {
-            $("#booking_result").replaceWith(
-                '<div id="booking_result" class="alert alert-danger">' +
-                    __("Failure") +
-                    "</div>"
-            );
+            var error = data.responseJSON.error;
+            var errorMessage = error ? error : "Failure";
+            $('#booking_result').replaceWith('<div id="booking_result" class="alert alert-danger">'+_(errorMessage)+'</div>');
         });
     } else {
         url += "/" + booking_id;
-- 
2.30.2