View | Details | Raw Unified | Return to bug 40134
Collapse All | Expand All

(-)a/Koha/Booking.pm (-10 / +120 lines)
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();
(-)a/Koha/REST/V1/Bookings.pm (-2 / +42 lines)
Lines 75-81 sub add { Link Here
75
    my $c = shift->openapi->valid_input or return;
75
    my $c = shift->openapi->valid_input or return;
76
76
77
    return try {
77
    return try {
78
        my $booking = Koha::Booking->new_from_api( $c->req->json );
78
        my $body = $c->req->json;
79
80
        # Validate that exactly one of item_id or itemtype_id is provided
81
        my $has_item_id     = defined $body->{item_id};
82
        my $has_itemtype_id = defined $body->{itemtype_id};
83
84
        if ( !$has_item_id && !$has_itemtype_id ) {
85
            return $c->render(
86
                status  => 400,
87
                openapi => { error => "Either item_id or itemtype_id must be provided" }
88
            );
89
        }
90
91
        if ( $has_item_id && $has_itemtype_id ) {
92
            return $c->render(
93
                status  => 400,
94
                openapi => { error => "Cannot specify both item_id and itemtype_id" }
95
            );
96
        }
97
98
        # Extract and remove itemtype_id from body (it's not a database column)
99
        my $itemtype_id = delete $body->{itemtype_id};
100
101
        my $booking = Koha::Booking->new_from_api($body);
102
103
        # Set transient itemtype filter if provided (for server-side optimal selection)
104
        if ($itemtype_id) {
105
            $booking->set_itemtype_filter($itemtype_id);
106
        }
107
79
        $booking->store;
108
        $booking->store;
80
        $booking->discard_changes;
109
        $booking->discard_changes;
81
        $c->res->headers->location( $c->req->url->to_string . '/' . $booking->booking_id );
110
        $c->res->headers->location( $c->req->url->to_string . '/' . $booking->booking_id );
Lines 117-123 sub update { Link Here
117
        unless $booking;
146
        unless $booking;
118
147
119
    return try {
148
    return try {
120
        $booking->set_from_api( $c->req->json );
149
        my $body = $c->req->json;
150
151
        # Extract and remove itemtype_id from body (it's not a database column)
152
        my $itemtype_id = delete $body->{itemtype_id};
153
154
        $booking->set_from_api($body);
155
156
        # Set transient itemtype filter if provided (for server-side optimal selection)
157
        if ($itemtype_id) {
158
            $booking->set_itemtype_filter($itemtype_id);
159
        }
160
121
        $booking->store();
161
        $booking->store();
122
        $booking->discard_changes;
162
        $booking->discard_changes;
123
        return $c->render( status => 200, openapi => $c->objects->to_api($booking) );
163
        return $c->render( status => 200, openapi => $c->objects->to_api($booking) );
(-)a/api/v1/swagger/definitions/booking.yaml (-2 / +6 lines)
Lines 25-34 properties: Link Here
25
    format: date-time
25
    format: date-time
26
    type: string
26
    type: string
27
  item_id:
27
  item_id:
28
    description: Internal item identifier
28
    description: Internal item identifier for specific item bookings
29
    type:
29
    type:
30
      - integer
30
      - integer
31
      - 'null'
31
      - 'null'
32
  itemtype_id:
33
    description: Item type identifier for "any item" bookings. Either item_id or itemtype_id must be provided. This is used for server-side optimal item selection and is not persisted.
34
    type:
35
      - string
36
      - 'null'
32
  item:
37
  item:
33
    description: Embedable item representation
38
    description: Embedable item representation
34
    type:
39
    type:
Lines 66-72 properties: Link Here
66
      - 'null'
71
      - 'null'
67
required:
72
required:
68
  - biblio_id
73
  - biblio_id
69
  - item_id
70
  - patron_id
74
  - patron_id
71
  - pickup_library_id
75
  - pickup_library_id
72
  - start_date
76
  - start_date
(-)a/api/v1/swagger/definitions/booking_patch.yaml (-2 / +6 lines)
Lines 23-32 properties: Link Here
23
    format: date-time
23
    format: date-time
24
    type: string
24
    type: string
25
  item_id:
25
  item_id:
26
    description: Internal item identifier
26
    description: Internal item identifier for specific item bookings
27
    type:
27
    type:
28
      - integer
28
      - integer
29
      - 'null'
29
      - 'null'
30
  itemtype_id:
31
    description: Item type identifier for "any item" bookings. Either item_id or itemtype_id must be provided. This is used for server-side optimal item selection and is not persisted.
32
    type:
33
      - string
34
      - 'null'
30
  modification_date:
35
  modification_date:
31
    description: Modification date and time of this booking
36
    description: Modification date and time of this booking
32
    readOnly: true
37
    readOnly: true
33
- 

Return to bug 40134