diff --git a/Koha/Library/Calendar.pm b/Koha/Library/Calendar.pm index 98ce63a383..f1ace8fbb2 100644 --- a/Koha/Library/Calendar.pm +++ b/Koha/Library/Calendar.pm @@ -330,8 +330,22 @@ sub days_forward { my $base_dt = $start_dt->clone(); + # Advance one open day at a time, skipping any closed days encountered + # along the way. days_mode is deliberately not consulted here: this + # method's purpose is to count open days forward (e.g. for a hold + # pickup window), not to align a recurring due-date to the same + # weekday, which is what next_open_days does under 'Dayweek'. while ( $num_days-- ) { - $base_dt = $self->next_open_days( $base_dt, 1 ); + $base_dt->add( days => 1 ); + my $i = 0; + while ( $self->is_holiday($base_dt) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) { + $base_dt->add( days => 1 ); + ++$i; + } + if ( $self->is_holiday($base_dt) ) { + Koha::Exceptions::Calendar::NoOpenDays->throw( + sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) ); + } } return $base_dt; @@ -761,10 +775,16 @@ syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'. $datetime = $calendar->days_forward($start_dt, $to_add) -Passed a Datetime and number of days, returns another Datetime representing -the next open day after adding the passed number of days. It is intended for -use to calculate the due date when useDaysMode syspref is set to either -'Datedue', 'Calendar' or 'Dayweek'. +Passed a DateTime and a number of days, returns a DateTime representing that +many open days after the starting date. Closed days (weekly closures and +holidays) are skipped without being counted toward $to_add, so the result is +always $to_add open days on from $start_dt. + +Unlike L, this method does not consult C: it +always advances one calendar day at a time. It is intended for windows that +should give the patron a fixed number of open days, such as a hold's pickup +shelf expiration -- not for aligning a recurring due-date to a particular +weekday. =head2 set_daysmode diff --git a/t/db_dependent/Calendar.t b/t/db_dependent/Calendar.t index f938b5c79a..66ac490298 100755 --- a/t/db_dependent/Calendar.t +++ b/t/db_dependent/Calendar.t @@ -430,7 +430,7 @@ $builder->build_object( ); subtest 'days_forward' => sub { - plan tests => 4; + plan tests => 9; my $forwarded_dt = $calendar->days_forward( $today, 2 ); my $expected = $today->clone->add( days => 2 ); @@ -448,6 +448,67 @@ subtest 'days_forward' => sub { $forwarded_dt = $calendar->days_forward( $today, -2 ); is( $forwarded_dt->ymd, $today->ymd, 'negative day should return start dt' ); + + # Bug 42608: days_forward must count open days regardless of useDaysMode. + # Previously Dayweek mode caused a 7-day jump when an intermediate + # closure landed on a normally-open weekday, over-extending hold + # pickup windows by a week. + my $dayweek_library = $builder->build_object( { class => 'Koha::Libraries' } ); + my $dayweek_branch = $dayweek_library->branchcode; + + # Sundays are the only weekly closure. + $schema->resultset('RepeatableHoliday')->create( + { + branchcode => $dayweek_branch, + weekday => 0, + description => q{Sunday}, + title => q{Sunday}, + } + ); + + # Single (one-off) holiday on a Monday 2 weeks out from a known Tuesday. + my $tuesday = dt_from_string('2026-04-28'); # Tuesday + my $bh = dt_from_string('2026-05-04'); # Monday Bank Holiday + $schema->resultset('SpecialHoliday')->create( + { + branchcode => $dayweek_branch, + day => $bh->day, + month => $bh->month, + year => $bh->year, + isexception => 0, + title => q{May Bank Holiday}, + description => q{May Bank Holiday}, + } + ); + + my $cache = Koha::Caches->get_instance(); + $cache->clear_from_cache( $dayweek_branch . '_holidays' ); + + for my $mode (qw(Calendar Datedue Dayweek Days)) { + my $dw_cal = Koha::Calendar->new( + branchcode => $dayweek_branch, + days_mode => $mode, + ); + + # Tue 2026-04-28 + 6 open days, skipping Sun 3 May and Mon 4 May, + # should land on Wed 2026-05-06 -- not Tue 2026-05-12. + my $got = $dw_cal->days_forward( $tuesday, 6 ); + is( + $got->ymd, '2026-05-06', + "days_forward(+6) over Sun + Mon BH from Tue lands on Wed (days_mode=$mode)" + ); + } + + # Confirm same behaviour over the BH from Monday (final day lands past + # the closures): Mon 27 Apr + 6 open days = Tue 5 May. + my $monday = dt_from_string('2026-04-27'); + my $dw_cal = Koha::Calendar->new( branchcode => $dayweek_branch, days_mode => 'Dayweek' ); + is( + $dw_cal->days_forward( $monday, 6 )->ymd, '2026-05-05', + 'days_forward(+6) from Mon over Sun + Mon BH lands on Tue (no week-jump in Dayweek)' + ); + + $cache->clear_from_cache( $dayweek_branch . '_holidays' ); }; subtest 'crossing_DST' => sub { diff --git a/t/db_dependent/Hold.t b/t/db_dependent/Hold.t index f7f831f779..786ce02c04 100755 --- a/t/db_dependent/Hold.t +++ b/t/db_dependent/Hold.t @@ -20,6 +20,7 @@ use Modern::Perl; use t::lib::Mocks; use C4::Context; use C4::Biblio qw( AddBiblio ); +use Koha::Caches; use Koha::Database; use Koha::Libraries; use C4::Calendar; @@ -317,7 +318,7 @@ subtest "store() tests" => sub { subtest "set_waiting() tests" => sub { - plan tests => 2; + plan tests => 3; $schema->storage->txn_begin(); @@ -331,6 +332,76 @@ subtest "set_waiting() tests" => sub { $hold->set_waiting(); isnt( $hold->waitingdate, undef, "Setting waiting when not waiting already should update waitingdate" ); + subtest 'expirationdate over a Bank Holiday with useDaysMode=Dayweek' => sub { + + # Bug 42608: when ExcludeHolidaysFromMaxPickUpDelay is on and + # useDaysMode=Dayweek, intermediate Bank Holiday Mondays should + # not cause the pickup window to jump a whole week forward. The + # patron should always get ReservesMaxPickUpDelay open days. + plan tests => 5; + + t::lib::Mocks::mock_preference( 'ReservesMaxPickUpDelay', 6 ); + t::lib::Mocks::mock_preference( 'ExcludeHolidaysFromMaxPickUpDelay', 1 ); + t::lib::Mocks::mock_preference( 'ExpireReservesOnHolidays', 1 ); + t::lib::Mocks::mock_preference( 'useDaysMode', 'Dayweek' ); + + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + my $branch = $library->branchcode; + + # Sundays weekly closed, single holiday on Mon 4 May 2026. + $schema->resultset('RepeatableHoliday')->create( + { + branchcode => $branch, + weekday => 0, + description => q{Sunday}, + title => q{Sunday}, + } + ); + $schema->resultset('SpecialHoliday')->create( + { + branchcode => $branch, + day => 4, + month => 5, + year => 2026, + isexception => 0, + title => q{May Bank Holiday}, + description => q{May Bank Holiday}, + } + ); + Koha::Caches->get_instance->clear_from_cache( $branch . '_holidays' ); + + my %expected = ( + '2026-04-27' => '2026-05-05', # Mon trapped, skip Sun + BH -> Tue + '2026-04-28' => '2026-05-06', # Tue trapped, skip Sun + BH -> Wed + '2026-04-29' => '2026-05-07', # Wed trapped -> Thu + '2026-04-30' => '2026-05-08', # Thu trapped -> Fri + '2026-05-01' => '2026-05-09', # Fri trapped -> Sat + ); + + for my $waitingdate ( sort keys %expected ) { + my $h = $builder->build_object( + { + class => 'Koha::Holds', + value => { + branchcode => $branch, + found => undef, + waitingdate => undef, + expirationdate => undef, + patron_expiration_date => undef, + } + } + ); + $h->waitingdate($waitingdate)->store; + $h->set_waiting; + is( + $h->expirationdate, $expected{$waitingdate}, + "waitingdate=$waitingdate => expirationdate=$expected{$waitingdate} (no week-jump)" + ); + } + + Koha::Caches->get_instance->clear_from_cache( $branch . '_holidays' ); + }; + $schema->storage->txn_rollback(); };