|
Lines 39-44
Koha::Booking - Koha Booking object class
Link Here
|
| 39 |
|
39 |
|
| 40 |
=head2 Class methods |
40 |
=head2 Class methods |
| 41 |
|
41 |
|
|
|
42 |
=head3 set_itemtype_filter |
| 43 |
|
| 44 |
$booking->set_itemtype_filter($itemtype_id); |
| 45 |
|
| 46 |
Sets a transient itemtype filter for item selection. This is used when |
| 47 |
creating "any item" bookings to filter items by type before optimal selection. |
| 48 |
This value is not persisted to the database. |
| 49 |
|
| 50 |
=cut |
| 51 |
|
| 52 |
sub set_itemtype_filter { |
| 53 |
my ( $self, $itemtype_id ) = @_; |
| 54 |
$self->{_itemtype_filter} = $itemtype_id; |
| 55 |
return $self; |
| 56 |
} |
| 57 |
|
| 42 |
=head3 biblio |
58 |
=head3 biblio |
| 43 |
|
59 |
|
| 44 |
Returns the related Koha::Biblio object for this booking |
60 |
Returns the related Koha::Biblio object for this booking |
|
Lines 261-277
sub _assign_item_for_booking {
Link Here
|
| 261 |
my $checkouts = |
277 |
my $checkouts = |
| 262 |
$biblio->current_checkouts->search( { date_due => { '>=' => $dtf->format_datetime($start_date) } } ); |
278 |
$biblio->current_checkouts->search( { date_due => { '>=' => $dtf->format_datetime($start_date) } } ); |
| 263 |
|
279 |
|
| 264 |
my $bookable_items = $biblio->bookable_items->search( |
280 |
# Build search conditions for bookable items |
| 265 |
{ |
281 |
my $item_search_conditions = { |
| 266 |
itemnumber => [ |
282 |
itemnumber => [ |
| 267 |
'-and' => { '-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query }, |
283 |
'-and' => { '-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query }, |
| 268 |
{ '-not_in' => $checkouts->_resultset->get_column('itemnumber')->as_query } |
284 |
{ '-not_in' => $checkouts->_resultset->get_column('itemnumber')->as_query } |
| 269 |
] |
285 |
] |
| 270 |
}, |
286 |
}; |
| 271 |
{ order_by => \'RAND()', rows => 1 } |
287 |
|
| 272 |
); |
288 |
# Filter by itemtype if specified (for "any item" bookings) |
|
|
289 |
if ( $self->{_itemtype_filter} ) { |
| 290 |
$item_search_conditions->{itype} = $self->{_itemtype_filter}; |
| 291 |
} |
| 273 |
|
292 |
|
| 274 |
my $itemnumber = $bookable_items->single->itemnumber; |
293 |
my $bookable_items = $biblio->bookable_items->search($item_search_conditions); |
|
|
294 |
|
| 295 |
# Use optimal selection instead of random selection |
| 296 |
my $optimal_item = $self->_select_optimal_item($bookable_items); |
| 297 |
|
| 298 |
Koha::Exceptions::Booking::Clash->throw("No available items found for booking") |
| 299 |
unless $optimal_item; |
| 300 |
|
| 301 |
my $itemnumber = $optimal_item->itemnumber; |
| 275 |
return $self->item_id($itemnumber); |
302 |
return $self->item_id($itemnumber); |
| 276 |
} |
303 |
} |
| 277 |
|
304 |
|
|
Lines 309-314
sub to_api_mapping {
Link Here
|
| 309 |
|
336 |
|
| 310 |
=head2 Internal methods |
337 |
=head2 Internal methods |
| 311 |
|
338 |
|
|
|
339 |
=head3 _select_optimal_item |
| 340 |
|
| 341 |
my $item_id = $self->_select_optimal_item($available_items); |
| 342 |
|
| 343 |
Selects the optimal item from a set of available items by choosing the item |
| 344 |
with the longest future availability after the booking ends. |
| 345 |
|
| 346 |
This maximizes future booking opportunities by preserving items with shorter |
| 347 |
future availability for bookings that specifically need them. |
| 348 |
|
| 349 |
=cut |
| 350 |
|
| 351 |
sub _select_optimal_item { |
| 352 |
my ( $self, $available_items ) = @_; |
| 353 |
|
| 354 |
return unless $available_items && $available_items->count > 0; |
| 355 |
|
| 356 |
# If only one item available, return it immediately |
| 357 |
return $available_items->next if $available_items->count == 1; |
| 358 |
|
| 359 |
my $check_date = dt_from_string( $self->end_date )->add( days => 1 ); |
| 360 |
my $dtf = Koha::Database->new->schema->storage->datetime_parser; |
| 361 |
|
| 362 |
# Get item IDs from the available items |
| 363 |
my @item_ids = map { $_->itemnumber } $available_items->as_list; |
| 364 |
|
| 365 |
# Find the item with the latest (furthest in future) next booking start date |
| 366 |
# Items with no future bookings will have NULL and sort first (most desirable) |
| 367 |
my $search_conditions = { |
| 368 |
item_id => { '-in' => \@item_ids }, |
| 369 |
start_date => { '>=' => $dtf->format_datetime($check_date) }, |
| 370 |
status => { '-not_in' => [ 'cancelled', 'completed' ] } |
| 371 |
}; |
| 372 |
|
| 373 |
# Exclude current booking if we're editing |
| 374 |
if ( $self->in_storage ) { |
| 375 |
$search_conditions->{booking_id} = { '!=' => $self->booking_id }; |
| 376 |
} |
| 377 |
|
| 378 |
# Query to find the earliest future booking for each item |
| 379 |
my $rs = Koha::Bookings->search( |
| 380 |
$search_conditions, |
| 381 |
{ |
| 382 |
select => [ 'item_id', { min => 'start_date', -as => 'next_booking_start' } ], |
| 383 |
as => [ 'item_id', 'next_booking_start' ], |
| 384 |
group_by => ['item_id'], |
| 385 |
} |
| 386 |
); |
| 387 |
|
| 388 |
# Get items that have future bookings, sorted by next booking date (desc = later is better) |
| 389 |
my %next_booking_by_item; |
| 390 |
while ( my $row = $rs->next ) { |
| 391 |
$next_booking_by_item{ $row->get_column('item_id') } = $row->get_column('next_booking_start'); |
| 392 |
} |
| 393 |
|
| 394 |
# Find the best item: items without future bookings first, then by latest next booking |
| 395 |
my $best_item_id; |
| 396 |
my $latest_next_booking; |
| 397 |
|
| 398 |
foreach my $item_id (@item_ids) { |
| 399 |
my $next_booking = $next_booking_by_item{$item_id}; |
| 400 |
|
| 401 |
# Items with no future bookings are best (infinite availability) |
| 402 |
if ( !defined $next_booking ) { |
| 403 |
$best_item_id = $item_id; |
| 404 |
last; |
| 405 |
} |
| 406 |
|
| 407 |
# Otherwise, prefer items with later (further in future) next bookings |
| 408 |
if ( !defined $latest_next_booking || $next_booking gt $latest_next_booking ) { |
| 409 |
$latest_next_booking = $next_booking; |
| 410 |
$best_item_id = $item_id; |
| 411 |
} |
| 412 |
} |
| 413 |
|
| 414 |
# Return the optimal item |
| 415 |
return Koha::Items->find($best_item_id) if $best_item_id; |
| 416 |
|
| 417 |
# Fallback: shouldn't reach here, but return first available item |
| 418 |
$available_items->reset; |
| 419 |
return $available_items->next; |
| 420 |
} |
| 421 |
|
| 312 |
=head3 _send_notice |
422 |
=head3 _send_notice |
| 313 |
|
423 |
|
| 314 |
$self->_send_notice(); |
424 |
$self->_send_notice(); |