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

(-)a/Koha/Biblio.pm (-6 / +36 lines)
Lines 342-353 sub can_be_edited { Link Here
342
=head3 check_booking
342
=head3 check_booking
343
343
344
  my $bookable =
344
  my $bookable =
345
    $biblio->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
345
    $biblio->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id, branchcode => $branchcode, itemtype => $itemtype ] } );
346
346
347
Returns a boolean denoting whether the passed booking can be made without clashing.
347
Returns a boolean denoting whether the passed booking can be made without clashing.
348
348
349
Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
349
Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
350
350
351
Optionally, you may pass a branchcode to use when looking up lead/trail circulation rules.
352
353
Optionally, you may pass an itemtype to use when looking up lead/trail circulation rules; falls back to a global rule if not provided.
354
351
=cut
355
=cut
352
356
353
sub check_booking {
357
sub check_booking {
Lines 356-366 sub check_booking { Link Here
356
    my $start_date = dt_from_string( $params->{start_date} );
360
    my $start_date = dt_from_string( $params->{start_date} );
357
    my $end_date   = dt_from_string( $params->{end_date} );
361
    my $end_date   = dt_from_string( $params->{end_date} );
358
    my $booking_id = $params->{booking_id};
362
    my $booking_id = $params->{booking_id};
363
    my $branchcode = $params->{branchcode};
364
    my $itemtype   = $params->{itemtype};
365
366
    my $lead_rule = Koha::CirculationRules->get_effective_rule(
367
        {
368
            rule_name  => 'bookings_lead_period',
369
            itemtype   => $itemtype,
370
            branchcode => $branchcode,
371
        }
372
    );
373
    my $lead_days = $lead_rule && defined $lead_rule->rule_value ? $lead_rule->rule_value : 0;
374
375
    my $trail_rule = Koha::CirculationRules->get_effective_rule(
376
        {
377
            rule_name  => 'bookings_trail_period',
378
            itemtype   => $itemtype,
379
            branchcode => $branchcode,
380
        }
381
    );
382
    my $trail_days = $trail_rule && defined $trail_rule->rule_value ? $trail_rule->rule_value : 0;
359
383
360
    my $bookable_items = $self->bookable_items;
384
    my $bookable_items = $self->bookable_items;
361
    my $total_bookable = $bookable_items->count;
385
    my $total_bookable = $bookable_items->count;
362
386
363
    my $dtf               = Koha::Database->new->schema->storage->datetime_parser;
387
    my $dtf = Koha::Database->new->schema->storage->datetime_parser;
388
389
    # Expand bounds to account for lead/trail buffer zones on both sides
390
    my $expanded_start = $start_date->clone->subtract( days => $lead_days + $trail_days );
391
    my $expanded_end   = $end_date->clone->add( days => $trail_days + $lead_days );
392
364
    my $existing_bookings = $self->bookings(
393
    my $existing_bookings = $self->bookings(
365
        {
394
        {
366
            '-and' => [
395
            '-and' => [
Lines 369-381 sub check_booking { Link Here
369
                        start_date => {
398
                        start_date => {
370
                            '-between' => [
399
                            '-between' => [
371
                                $dtf->format_datetime($start_date),
400
                                $dtf->format_datetime($start_date),
372
                                $dtf->format_datetime($end_date)
401
                                $dtf->format_datetime($expanded_end),
373
                            ]
402
                            ]
374
                        },
403
                        },
375
                        end_date => {
404
                        end_date => {
376
                            '-between' => [
405
                            '-between' => [
377
                                $dtf->format_datetime($start_date),
406
                                $dtf->format_datetime($expanded_start),
378
                                $dtf->format_datetime($end_date)
407
                                $dtf->format_datetime($end_date),
379
                            ]
408
                            ]
380
                        },
409
                        },
381
                        {
410
                        {
Lines 400-406 sub check_booking { Link Here
400
429
401
    my $checkouts = $self->current_checkouts->search(
430
    my $checkouts = $self->current_checkouts->search(
402
        {
431
        {
403
            date_due        => { '>=' => $dtf->format_datetime($start_date) },
432
            # Item must be returned at least lead_days before booking starts
433
            date_due        => { '>=' => $dtf->format_datetime( $start_date->clone->subtract( days => $lead_days ) ) },
404
            "me.itemnumber" => {
434
            "me.itemnumber" => {
405
                '-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query,
435
                '-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query,
406
                '-in'     => $bookable_item_ids,
436
                '-in'     => $bookable_item_ids,
(-)a/Koha/Booking.pm (-4 / +16 lines)
Lines 186-192 sub store { Link Here
186
                    {
186
                    {
187
                        start_date => $self->start_date,
187
                        start_date => $self->start_date,
188
                        end_date   => $self->end_date,
188
                        end_date   => $self->end_date,
189
                        booking_id => $self->in_storage ? $self->booking_id : undef
189
                        booking_id => $self->in_storage ? $self->booking_id : undef,
190
                        branchcode => $self->pickup_library_id,
190
                    }
191
                    }
191
                    );
192
                    );
192
193
Lines 196-202 sub store { Link Here
196
                    {
197
                    {
197
                        start_date => $self->start_date,
198
                        start_date => $self->start_date,
198
                        end_date   => $self->end_date,
199
                        end_date   => $self->end_date,
199
                        booking_id => $self->in_storage ? $self->booking_id : undef
200
                        booking_id => $self->in_storage ? $self->booking_id : undef,
201
                        branchcode => $self->pickup_library_id,
202
                        itemtype   => $self->item_id ? $self->item->effective_itemtype : undef,
200
                    }
203
                    }
201
                    );
204
                    );
202
            }
205
            }
Lines 282-289 sub _assign_item_for_booking { Link Here
282
        }
285
        }
283
    );
286
    );
284
287
285
    my $checkouts =
288
    my $lead_rule = Koha::CirculationRules->get_effective_rule(
286
        $biblio->current_checkouts->search( { date_due => { '>=' => $dtf->format_datetime($start_date) } } );
289
        {
290
            rule_name  => 'bookings_lead_period',
291
            itemtype   => $biblio->biblioitem->itemtype,
292
            branchcode => $self->pickup_library_id,
293
        }
294
    );
295
    my $lead_days = $lead_rule && defined $lead_rule->rule_value ? $lead_rule->rule_value : 0;
296
297
    my $checkouts = $biblio->current_checkouts->search(
298
        { date_due => { '>=' => $dtf->format_datetime( $start_date->clone->subtract( days => $lead_days ) ) } } );
287
299
288
    # Build search conditions for bookable items
300
    # Build search conditions for bookable items
289
    my $item_search_conditions = {
301
    my $item_search_conditions = {
(-)a/Koha/Item.pm (-5 / +38 lines)
Lines 726-737 sub find_booking { Link Here
726
=head3 check_booking
726
=head3 check_booking
727
727
728
    my $bookable =
728
    my $bookable =
729
        $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id ] } );
729
        $item->check_booking( { start_date => $datetime, end_date => $datetime, [ booking_id => $booking_id, branchcode => $branchcode ] } );
730
730
731
Returns a boolean denoting whether the passed booking can be made without clashing.
731
Returns a boolean denoting whether the passed booking can be made without clashing.
732
732
733
Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
733
Optionally, you may pass a booking id to exclude from the checks; This is helpful when you are updating an existing booking.
734
734
735
Optionally, you may pass a branchcode to use when looking up lead/trail circulation rules.
736
735
=cut
737
=cut
736
738
737
sub check_booking {
739
sub check_booking {
Lines 740-752 sub check_booking { Link Here
740
    my $start_date = dt_from_string( $params->{start_date} );
742
    my $start_date = dt_from_string( $params->{start_date} );
741
    my $end_date   = dt_from_string( $params->{end_date} );
743
    my $end_date   = dt_from_string( $params->{end_date} );
742
    my $booking_id = $params->{booking_id};
744
    my $booking_id = $params->{booking_id};
745
    my $branchcode = $params->{branchcode};
746
747
    # Get lead and trail periods for this item type to enforce buffer zones
748
    my $lead_rule = Koha::CirculationRules->get_effective_rule(
749
        {
750
            rule_name  => 'bookings_lead_period',
751
            itemtype   => $self->effective_itemtype,
752
            branchcode => $branchcode,
753
        }
754
    );
755
    my $lead_days = $lead_rule && defined $lead_rule->rule_value ? $lead_rule->rule_value : 0;
756
757
    my $trail_rule = Koha::CirculationRules->get_effective_rule(
758
        {
759
            rule_name  => 'bookings_trail_period',
760
            itemtype   => $self->effective_itemtype,
761
            branchcode => $branchcode,
762
        }
763
    );
764
    my $trail_days = $trail_rule && defined $trail_rule->rule_value ? $trail_rule->rule_value : 0;
743
765
744
    if ( my $checkout = $self->checkout ) {
766
    if ( my $checkout = $self->checkout ) {
745
        return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
767
768
        # Item must be returned at least lead_days before booking starts
769
        return 0
770
            if ( dt_from_string( $checkout->date_due ) >= $start_date->clone->subtract( days => $lead_days ) );
746
    }
771
    }
747
772
748
    my $dtf = Koha::Database->new->schema->storage->datetime_parser;
773
    my $dtf = Koha::Database->new->schema->storage->datetime_parser;
749
774
775
    # To check whether effective periods (core period ± lead/trail) overlap, we expand
776
    # the query bounds. For items of the same effective itemtype the lead/trail values
777
    # are identical for both the new and existing bookings, so the overlap condition
778
    # becomes: existing_start <= new_end + trail + lead
779
    #       AND existing_end   >= new_start - lead - trail
780
    my $expanded_start = $start_date->clone->subtract( days => $lead_days + $trail_days );
781
    my $expanded_end   = $end_date->clone->add( days => $trail_days + $lead_days );
782
750
    my $existing_bookings = $self->bookings(
783
    my $existing_bookings = $self->bookings(
751
        {
784
        {
752
            '-and' => [
785
            '-and' => [
Lines 755-767 sub check_booking { Link Here
755
                        start_date => {
788
                        start_date => {
756
                            '-between' => [
789
                            '-between' => [
757
                                $dtf->format_datetime($start_date),
790
                                $dtf->format_datetime($start_date),
758
                                $dtf->format_datetime($end_date)
791
                                $dtf->format_datetime($expanded_end),
759
                            ]
792
                            ]
760
                        },
793
                        },
761
                        end_date => {
794
                        end_date => {
762
                            '-between' => [
795
                            '-between' => [
763
                                $dtf->format_datetime($start_date),
796
                                $dtf->format_datetime($expanded_start),
764
                                $dtf->format_datetime($end_date)
797
                                $dtf->format_datetime($end_date),
765
                            ]
798
                            ]
766
                        },
799
                        },
767
                        {
800
                        {
(-)a/t/db_dependent/Koha/Booking.t (-2 / +176 lines)
Lines 20-32 Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
use utf8;
21
use utf8;
22
22
23
use Test::More tests => 7;
23
use Test::More tests => 8;
24
use Test::NoWarnings;
24
use Test::NoWarnings;
25
25
26
use Test::Warn;
26
use Test::Warn;
27
27
28
use Test::Exception;
28
use Test::Exception;
29
29
30
use Koha::CirculationRules;
30
use Koha::DateUtils qw( dt_from_string );
31
use Koha::DateUtils qw( dt_from_string );
31
use Koha::Notice::Template;
32
use Koha::Notice::Template;
32
use Koha::Notice::Templates;
33
use Koha::Notice::Templates;
Lines 1227-1229 subtest 'store() skips clash detection on terminal status transition' => sub { Link Here
1227
1228
1228
    $schema->storage->txn_rollback;
1229
    $schema->storage->txn_rollback;
1229
};
1230
};
1230
- 
1231
1232
subtest 'lead/trail period enforcement tests' => sub {
1233
    plan tests => 8;
1234
    $schema->storage->txn_begin;
1235
1236
    my $patron = $builder->build_object( { class => "Koha::Patrons" } );
1237
    t::lib::Mocks::mock_userenv( { patron => $patron } );
1238
1239
    my $item       = $builder->build_sample_item( { bookable => 1 } );
1240
    my $branchcode = $item->homebranch;
1241
    my $itype      = $item->effective_itemtype;
1242
1243
    # Set lead=3 days, trail=2 days
1244
    Koha::CirculationRules->set_rule(
1245
        {
1246
            branchcode => $branchcode,
1247
            itemtype   => $itype,
1248
            rule_name  => 'bookings_lead_period',
1249
            rule_value => 3,
1250
        }
1251
    );
1252
    Koha::CirculationRules->set_rule(
1253
        {
1254
            branchcode => $branchcode,
1255
            itemtype   => $itype,
1256
            rule_name  => 'bookings_trail_period',
1257
            rule_value => 2,
1258
        }
1259
    );
1260
1261
    # Existing booking B1: today+10 to today+20
1262
    # B1 effective period (with lead=3, trail=2): today+7 to today+22
1263
    my $b1_start = dt_from_string->add( days => 10 )->truncate( to => 'day' );
1264
    my $b1_end   = dt_from_string->add( days => 20 )->truncate( to => 'day' );
1265
    my $booking1 = Koha::Booking->new(
1266
        {
1267
            patron_id         => $patron->borrowernumber,
1268
            biblio_id         => $item->biblio->biblionumber,
1269
            item_id           => $item->itemnumber,
1270
            pickup_library_id => $branchcode,
1271
            start_date        => $b1_start,
1272
            end_date          => $b1_end,
1273
        }
1274
    )->store;
1275
    ok( $booking1->in_storage, 'First booking stored OK' );
1276
1277
    # FAIL: B2 ends inside B1's lead period (B2: today+5 to today+8, B1 lead starts today+7)
1278
    throws_ok {
1279
        Koha::Booking->new(
1280
            {
1281
                patron_id         => $patron->borrowernumber,
1282
                biblio_id         => $item->biblio->biblionumber,
1283
                item_id           => $item->itemnumber,
1284
                pickup_library_id => $branchcode,
1285
                start_date        => dt_from_string->add( days => 5 )->truncate( to => 'day' ),
1286
                end_date          => dt_from_string->add( days => 8 )->truncate( to => 'day' ),
1287
            }
1288
        )->store;
1289
    }
1290
    'Koha::Exceptions::Booking::Clash',
1291
        'Throws when new booking ends inside an existing booking lead period';
1292
1293
    # SUCCESS: B2 ends before B1's effective period (B2: today+3 to today+4)
1294
    # B2 effective end = today+4+trail=today+6 < B1 effective start today+7
1295
    my $booking2 = Koha::Booking->new(
1296
        {
1297
            patron_id         => $patron->borrowernumber,
1298
            biblio_id         => $item->biblio->biblionumber,
1299
            item_id           => $item->itemnumber,
1300
            pickup_library_id => $branchcode,
1301
            start_date        => dt_from_string->add( days => 3 )->truncate( to => 'day' ),
1302
            end_date          => dt_from_string->add( days => 4 )->truncate( to => 'day' ),
1303
        }
1304
    )->store;
1305
    ok( $booking2->in_storage, 'Booking OK when it ends before existing booking effective period' );
1306
1307
    # FAIL: B3 starts inside B1's trail period (B3: today+22 to today+25, B1 trail ends today+22)
1308
    throws_ok {
1309
        Koha::Booking->new(
1310
            {
1311
                patron_id         => $patron->borrowernumber,
1312
                biblio_id         => $item->biblio->biblionumber,
1313
                item_id           => $item->itemnumber,
1314
                pickup_library_id => $branchcode,
1315
                start_date        => dt_from_string->add( days => 22 )->truncate( to => 'day' ),
1316
                end_date          => dt_from_string->add( days => 25 )->truncate( to => 'day' ),
1317
            }
1318
        )->store;
1319
    }
1320
    'Koha::Exceptions::Booking::Clash',
1321
        'Throws when new booking starts inside an existing booking trail period';
1322
1323
    # FAIL: B3 lead period overlaps B1's trail period
1324
    # B3: today+23 to today+25, lead=3 → B3 eff start=today+20, which overlaps B1 end at today+20
1325
    throws_ok {
1326
        Koha::Booking->new(
1327
            {
1328
                patron_id         => $patron->borrowernumber,
1329
                biblio_id         => $item->biblio->biblionumber,
1330
                item_id           => $item->itemnumber,
1331
                pickup_library_id => $branchcode,
1332
                start_date        => dt_from_string->add( days => 23 )->truncate( to => 'day' ),
1333
                end_date          => dt_from_string->add( days => 25 )->truncate( to => 'day' ),
1334
            }
1335
        )->store;
1336
    }
1337
    'Koha::Exceptions::Booking::Clash',
1338
        "Throws when new booking's lead period overlaps an existing booking's trail period";
1339
1340
    # SUCCESS: B3 starts after B1's full effective period ends
1341
    # B3: today+26 to today+30, B3 eff start = today+26-3=today+23 > B1 eff end today+22
1342
    my $booking3 = Koha::Booking->new(
1343
        {
1344
            patron_id         => $patron->borrowernumber,
1345
            biblio_id         => $item->biblio->biblionumber,
1346
            item_id           => $item->itemnumber,
1347
            pickup_library_id => $branchcode,
1348
            start_date        => dt_from_string->add( days => 26 )->truncate( to => 'day' ),
1349
            end_date          => dt_from_string->add( days => 30 )->truncate( to => 'day' ),
1350
        }
1351
    )->store;
1352
    ok(
1353
        $booking3->in_storage,
1354
        'Booking OK when new effective period starts after existing effective period ends'
1355
    );
1356
1357
    # Checkout tests: item must be returned at least lead_days before booking start.
1358
    # Use dates outside all existing bookings' effective periods (B3 effective end = today+32,
1359
    # so B4 effective start must be > today+32 → B4 start >= today+36).
1360
    # FAIL: checkout due today+34, booking start today+36 (lead=3 → must return by today+33)
1361
    # today+34 >= today+33 → FAIL
1362
    $builder->build_object(
1363
        {
1364
            class => 'Koha::Checkouts',
1365
            value => {
1366
                itemnumber => $item->itemnumber,
1367
                date_due   => dt_from_string->add( days => 34 )->truncate( to => 'day' )->datetime(q{ }),
1368
            }
1369
        }
1370
    );
1371
    throws_ok {
1372
        Koha::Booking->new(
1373
            {
1374
                patron_id         => $patron->borrowernumber,
1375
                biblio_id         => $item->biblio->biblionumber,
1376
                item_id           => $item->itemnumber,
1377
                pickup_library_id => $branchcode,
1378
                start_date        => dt_from_string->add( days => 36 )->truncate( to => 'day' ),
1379
                end_date          => dt_from_string->add( days => 40 )->truncate( to => 'day' ),
1380
            }
1381
        )->store;
1382
    }
1383
    'Koha::Exceptions::Booking::Clash',
1384
        'Throws when checked-out item is due within the booking lead period';
1385
1386
    # SUCCESS: checkout due today+32, booking start today+36 (lead=3 → must return by today+33)
1387
    # today+32 < today+33 → OK
1388
    # Update the checkout to an earlier due date
1389
    Koha::Checkouts->search( { itemnumber => $item->itemnumber } )
1390
        ->update( { date_due => dt_from_string->add( days => 32 )->truncate( to => 'day' )->datetime(q{ }) } );
1391
    my $booking4 = Koha::Booking->new(
1392
        {
1393
            patron_id         => $patron->borrowernumber,
1394
            biblio_id         => $item->biblio->biblionumber,
1395
            item_id           => $item->itemnumber,
1396
            pickup_library_id => $branchcode,
1397
            start_date        => dt_from_string->add( days => 36 )->truncate( to => 'day' ),
1398
            end_date          => dt_from_string->add( days => 40 )->truncate( to => 'day' ),
1399
        }
1400
    )->store;
1401
    ok( $booking4->in_storage, 'Booking OK when checkout is due before the lead period starts' );
1402
1403
    $schema->storage->txn_rollback;
1404
};

Return to bug 41514