From e7b8b1c50f7c278262deb0be07a067fdaf50c11e Mon Sep 17 00:00:00 2001 From: Thibaud Guillot Date: Fri, 27 Feb 2026 16:23:30 +0100 Subject: [PATCH] Bug 41993: Add a page to show items available for bookings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test plan: 1) Apply this patch, rebuild sources and restart_all 2) On Circulation module you will see a new links into "Holds and bookings" section, click on "Items available for bookings" 3) First of all the datatable list your bookable items but not filtered 4) On the left you have many filters for the datatable, please note that dates are modified on server side to include lead and trail periods if they exist. Sponsored by: Loire Forez Agglomération Signed-off-by: Owen Leonard --- Koha/Items.pm | 138 ++++++++- Koha/REST/V1/Items.pm | 32 ++ admin/columns_settings.yml | 21 ++ .../paths/items_available_for_booking.yaml | 81 +++++ api/v1/swagger/swagger.yaml | 2 + circ/available-bookings.pl | 52 ++++ .../prog/en/includes/circ-nav.inc | 16 +- .../en/modules/circ/available-bookings.tt | 276 ++++++++++++++++++ .../prog/en/modules/circ/circulation-home.tt | 3 + 9 files changed, 609 insertions(+), 12 deletions(-) create mode 100644 api/v1/swagger/paths/items_available_for_booking.yaml create mode 100644 circ/available-bookings.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/circ/available-bookings.tt diff --git a/Koha/Items.pm b/Koha/Items.pm index 862631fbfb3..4eec60514e5 100644 --- a/Koha/Items.pm +++ b/Koha/Items.pm @@ -36,6 +36,7 @@ use Koha::CirculationRules; use base qw(Koha::Objects); use Koha::SearchEngine::Indexer; +use Koha::DateUtils qw( dt_from_string); =head1 NAME @@ -192,16 +193,23 @@ sub filter_out_lost { =head3 filter_by_bookable my $filterd_items = $items->filter_by_bookable; + my $filterd_items = $items->filter_by_bookable({ + start_date => '2024-01-01', + end_date => '2024-01-07' + }); Returns a new resultset, containing only those items that are allowed to be booked. +If start_date and end_date are provided, also filters out items that are not available +during the specified period (not booked and not checked out). =cut sub filter_by_bookable { - my ($self) = @_; + my ($self, $params) = @_; + my $bookable_items; if ( !C4::Context->preference('item-level_itypes') ) { - return $self->search( + $bookable_items = $self->search( [ { bookable => 1 }, { @@ -212,17 +220,125 @@ sub filter_by_bookable { ], { join => 'biblioitem' } ); + } else { + $bookable_items = $self->search( + [ + { bookable => 1 }, + { + bookable => undef, + itype => { -in => [ Koha::ItemTypes->search( { bookable => 1 } )->get_column('itemtype') ] } + }, + ] + ); } - return $self->search( - [ - { bookable => 1 }, + return $self->_filter_by_availability($bookable_items, $params); +} + +=head3 _filter_by_availability + +Internal helper method to filter items by availability during a date range. + +=cut + +sub _filter_by_availability { + my ($self, $bookable_items, $params) = @_; + + return $bookable_items unless $params && $params->{start_date} && $params->{end_date}; + + my $start_date = dt_from_string( $params->{start_date} ); + my $end_date = dt_from_string( $params->{end_date} ); + + $start_date->set_hour(0)->set_minute(0)->set_second(0); + $end_date->set_hour(23)->set_minute(59)->set_second(59); + + my $dtf = Koha::Database->new->schema->storage->datetime_parser; + my @excluded_items; + + while ( my $item = $bookable_items->next ) { + + my $processing_rules = Koha::CirculationRules->get_effective_rules( { - bookable => undef, - itype => { -in => [ Koha::ItemTypes->search( { bookable => 1 } )->get_column('itemtype') ] } - }, - ] - ); + categorycode => '*', + itemtype => $item->effective_itemtype, + branchcode => $item->homebranch, + rules => [ 'bookings_lead_period', 'bookings_trail_period' ] + } + ); + + my $item_start_date = $start_date->clone; + my $item_end_date = $end_date->clone; + + if (defined $processing_rules->{'bookings_lead_period'} && $processing_rules->{'bookings_lead_period'} ne '') { + my $lead_days = $processing_rules->{'bookings_lead_period'}; + $item_start_date->subtract(days => $lead_days); + $item_start_date->set_hour(0)->set_minute(0)->set_second(0); + } + + if (defined $processing_rules->{'bookings_trail_period'} && $processing_rules->{'bookings_trail_period'} ne '') { + my $trail_days = $processing_rules->{'bookings_trail_period'}; + $item_end_date->add(days => $trail_days); + $item_end_date->set_hour(23)->set_minute(59)->set_second(59); + } + + my $existing_bookings = $item->bookings( + { + '-and' => [ + { + '-or' => [ + { + start_date => { + '-between' => [ + $dtf->format_datetime($item_start_date), + $dtf->format_datetime($item_end_date) + ] + } + }, + { + end_date => { + '-between' => [ + $dtf->format_datetime($item_start_date), + $dtf->format_datetime($item_end_date) + ] + } + }, + { + start_date => { '<' => $dtf->format_datetime($item_start_date) }, + end_date => { '>' => $dtf->format_datetime($item_end_date) } + } + ] + }, + { status => { '-not_in' => [ 'cancelled', 'completed' ] } } + ] + } + ); + + my $checkout = $item->checkout; + my $checkout_conflicts = 0; + if ($checkout) { + my $due_date = dt_from_string($checkout->date_due); + $checkout_conflicts = 1 if $due_date >= $item_start_date; + } + + if ($existing_bookings->count > 0 || $checkout_conflicts) { + push @excluded_items, $item->itemnumber; + # Debug - uncomment to see why items are excluded: + # warn "EXCLUDED Item #" . $item->itemnumber . " - Bookings: " . $existing_bookings->count . ", Checkout conflicts: $checkout_conflicts, Lead: " . ($processing_rules->{'bookings_lead_period'} // 'none') . ", Trail: " . ($processing_rules->{'bookings_trail_period'} // 'none'); + } else { + # Debug - uncomment to see items that pass: + # warn "PASSED Item #" . $item->itemnumber . " - Lead: " . ($processing_rules->{'bookings_lead_period'} // 'none') . ", Trail: " . ($processing_rules->{'bookings_trail_period'} // 'none'); + } + } + + $bookable_items->reset; + + if (@excluded_items) { + return $bookable_items->search({ + 'me.itemnumber' => { '-not_in' => \@excluded_items } + }); + } + + return $bookable_items; } =head3 filter_by_checked_out @@ -773,4 +889,4 @@ Martin Renvoize =cut -1; +1; \ No newline at end of file diff --git a/Koha/REST/V1/Items.pm b/Koha/REST/V1/Items.pm index 07a8a4f03e5..9ddcb46d787 100644 --- a/Koha/REST/V1/Items.pm +++ b/Koha/REST/V1/Items.pm @@ -22,6 +22,8 @@ use Mojo::Base 'Mojolicious::Controller'; use C4::Circulation qw( barcodedecode ); use Koha::Items; +use Koha::DateUtils; +use Koha::Database; use List::MoreUtils qw( any ); use Try::Tiny qw( catch try ); @@ -424,4 +426,34 @@ sub remove_from_bundle { }; } +=head3 available_for_booking + +Controller function that handles retrieving items available for booking + +=cut + +sub available_for_booking { + my $c = shift->openapi->valid_input or return; + + return try { + my $start_date = $c->param('start_date'); + my $end_date = $c->param('end_date'); + + $c->req->params->remove('start_date'); + $c->req->params->remove('end_date'); + my $items_set = Koha::Items->new->filter_by_bookable( + ($start_date && $end_date) ? { + start_date => $start_date, + end_date => $end_date, + } : undef + ); + + my $items = $c->objects->search( $items_set ); + return $c->render( status => 200, openapi => $items ); + + } catch { + $c->unhandled_exception($_); + }; +} + 1; diff --git a/admin/columns_settings.yml b/admin/columns_settings.yml index 75f73437fee..836677dacf8 100644 --- a/admin/columns_settings.yml +++ b/admin/columns_settings.yml @@ -2400,6 +2400,27 @@ modules: - columnname: booking_dates + available-bookings: + available-items: + default_sort_order: 2 + columns: + - + columnname: holding_library + - + columnname: home_library + - + columnname: title + - + columnname: item_type + - + columnname: barcode + - + columnname: call_number + - + columnname: location + - + columnname: localuse + opac: biblio-detail: holdingst: diff --git a/api/v1/swagger/paths/items_available_for_booking.yaml b/api/v1/swagger/paths/items_available_for_booking.yaml new file mode 100644 index 00000000000..8ac5142c928 --- /dev/null +++ b/api/v1/swagger/paths/items_available_for_booking.yaml @@ -0,0 +1,81 @@ +--- +/items/available_for_booking: + get: + x-mojo-to: Items#available_for_booking + operationId: listItemsAvailableForBooking + tags: + - items + summary: List items available for booking + parameters: + - description: Start date for availability period (YYYY-MM-DD format) + in: query + name: start_date + required: false + type: string + - description: End date for availability period (YYYY-MM-DD format) + in: query + name: end_date + required: false + type: string + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - +strings + - biblio + - effective_bookable + - home_library + - holding_library + - item_type + collectionFormat: csv + - $ref: "../swagger.yaml#/parameters/match" + - $ref: "../swagger.yaml#/parameters/order_by" + - $ref: "../swagger.yaml#/parameters/page" + - $ref: "../swagger.yaml#/parameters/per_page" + - $ref: "../swagger.yaml#/parameters/q_param" + - $ref: "../swagger.yaml#/parameters/q_body" + - $ref: "../swagger.yaml#/parameters/request_id_header" + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: A list of items available for booking + schema: + type: array + items: + $ref: "../swagger.yaml#/definitions/item" + "400": + description: | + Bad request. Possible `error_code` attribute values: + + * `invalid_query` + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + circulate: manage_bookings \ No newline at end of file diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 9a9b7af7565..2ea7113234a 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -463,6 +463,8 @@ paths: $ref: ./paths/item_types.yaml#/~1item_types /items: $ref: ./paths/items.yaml#/~1items + /items/available_for_booking: + $ref: ./paths/items_available_for_booking.yaml#/~1items~1available_for_booking "/items/{item_id}": $ref: "./paths/items.yaml#/~1items~1{item_id}" "/items/{item_id}/bookings": diff --git a/circ/available-bookings.pl b/circ/available-bookings.pl new file mode 100644 index 00000000000..d22014175c6 --- /dev/null +++ b/circ/available-bookings.pl @@ -0,0 +1,52 @@ +#!/usr/bin/perl + +# Copyright 2024 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use CGI qw ( -utf8 ); + +use C4::Context; +use C4::Output qw( output_html_with_http_headers ); +use C4::Auth qw( get_template_and_user ); + +use Koha::DateUtils qw(dt_from_string); + +my $input = CGI->new; +my ( $template, $borrowernumber, $cookie, $flags ) = get_template_and_user( + { + template_name => "circ/available-bookings.tt", + query => $input, + type => "intranet", + flagsrequired => { circulate => 'manage_bookings' }, + } +); + +my $branchcode = defined( $input->param('library') ) ? $input->param('library') : C4::Context->userenv->{'branch'}; + +my $today = dt_from_string(); +my $startdate = $today->clone->truncate( to => 'day' ); +my $enddate = $startdate->clone->add( days => 7 ); + +$template->param( + branchcode => $branchcode, + start_date_default => $startdate, + end_date_default => $enddate, +); + +output_html_with_http_headers $input, $cookie, $template->output; \ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/circ-nav.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/circ-nav.inc index 07cb781c347..9d8832e5638 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/circ-nav.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/circ-nav.inc @@ -64,7 +64,21 @@ - [% IF Koha.Preference('UseRecalls') and CAN_user_recalls %] + +
+ [% IF ( CAN_user_circulate_manage_bookings ) %] +
Bookings
+ + [% END %] + + [% IF Koha.Preference('UseRecalls') and CAN_user_recalls %]
Recalls
  • diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/available-bookings.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/available-bookings.tt new file mode 100644 index 00000000000..d41ad3d2d04 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/available-bookings.tt @@ -0,0 +1,276 @@ +[% USE raw %] +[% USE Asset %] +[% USE Branches %] +[% USE Koha %] +[% USE KohaDates %] +[% USE TablesSettings %] +[% USE To %] +[% USE ItemTypes %] +[% PROCESS 'i18n.inc' %] +[% SET footerjs = 1 %] +[% INCLUDE 'doc-head-open.inc' %] +[% FILTER collapse %] + [% t("Items available for booking") | html %] › + [% t("Circulation") | html %] › + [% t("Koha") | html %] +[% END %] +[% INCLUDE 'doc-head-close.inc' %] + + + +[% WRAPPER 'header.inc' %] + [% INCLUDE 'circ-search.inc' %] +[% END %] + +[% WRAPPER 'sub-header.inc' %] + [% WRAPPER breadcrumbs %] + [% WRAPPER breadcrumb_item %] + Circulation + [% END %] + [% WRAPPER breadcrumb_item bc_active= 1 %] + Items available for booking + [% END %] + [% END #/ WRAPPER breadcrumbs %] +[% END #/ WRAPPER sub-header.inc %] + +
    +
    + + +
    +
    + [% INCLUDE 'messages.inc' %] +

    Items available for booking

    +

    Items with bookable status that are not booked and not checked out for the requested period.

    +
    +
    +
    +
    +
    + + +
    + +
    +
    + +
    + +[% MACRO jsinclude BLOCK %] +[% INCLUDE 'calendar.inc' %] +[% INCLUDE 'datatables.inc' %] +[% INCLUDE 'js-biblio-format.inc' %] +[% INCLUDE 'js-date-format.inc' %] + + +[% END %] + +[% INCLUDE 'intranet-bottom.inc' %] \ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation-home.tt index 5def67f6558..7488a5ee764 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation-home.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation-home.tt @@ -100,6 +100,9 @@
  • Bookings to collect
  • +
  • + Items available for bookings +
  • [% END %]
-- 2.39.5