From 5e8ea286fc037997658a9f5292eb7510c547a26f Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Tue, 27 Mar 2018 11:38:40 -0400 Subject: [PATCH] LP1829295 Hold shelf expire date honors closed + hours Hold shelf expire date calculation takes hours of operation and org unit closed days into consideration. Adds a new open-ils.actor.org_unit.open_day_range API call for testing the new code and for possible future use. Signed-off-by: Bill Erickson --- .../lib/OpenILS/Application/Actor/ClosedDates.pm | 22 +++ .../perlmods/lib/OpenILS/Application/AppUtils.pm | 159 +++++++++++++++++++++ .../perlmods/lib/OpenILS/Application/Circ/Holds.pm | 38 +++-- 3 files changed, 199 insertions(+), 20 deletions(-) diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/ClosedDates.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/ClosedDates.pm index fc53c1681a..9e0b55da9c 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/ClosedDates.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/ClosedDates.pm @@ -348,6 +348,28 @@ sub closed_dates_overlap { 'open-ils.storage.actor.org_unit.closed_date.overlap', $orgid, $date ); } +__PACKAGE__->register_method( + method => 'org_unit_open_day_range', + api_name => 'open-ils.actor.org_unit.open_day_range', + signature => q/ + Returns a date representing the final day in a series of + days that include the desired number of open days for the + requested org unit. + + The calculation starts at noon tomorrow and counts forward + until enough open days have been found to span the selected + day count. + + For example, if today was Jan 1 and a day count of 3 was + requested, assuming no closed date or hours of operation + collisions, the API would return Jan 4. + / +); + +sub org_unit_open_day_range { + my ($self, $client, $org_id, $open_day_count) = @_; + return $U->org_unit_open_days($org_id, $open_day_count); +} diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm index 5ed0e25436..014556783e 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm @@ -5,6 +5,7 @@ use base qw/OpenILS::Application/; use OpenSRF::Utils::Cache; use OpenSRF::Utils::Logger qw/$logger/; use OpenILS::Utils::ModsParser; +use OpenSRF::Utils qw/:datetime/; use OpenSRF::EX qw(:try); use OpenILS::Event; use Data::Dumper; @@ -16,6 +17,7 @@ use UUID::Tiny; use Encode; use DateTime; use DateTime::Format::ISO8601; +use DateTime::SpanSet; use List::MoreUtils qw/uniq/; use Digest::MD5 qw(md5_hex); @@ -2412,5 +2414,162 @@ sub verify_migrated_user_password { } +# Returns a date representing the final day in a series of +# days that include the desired number of open days for the +# requested org unit. +# +# The calculation starts at noon tomorrow and counts forward +# until enough open days have been found to span the selected +# day count. +# +# For example, if today was Jan 1 and a day count of 3 was +# requested, assuming no closed date or hours of operation +# collisions, the API would return Jan 4. +sub org_unit_open_days { + my ($class, $org_id, $day_count, $e) = @_; + $e ||= OpenILS::Utils::CStoreEditor->new; + + my $spanset; + my $date = DateTime->now(time_zone => 'local'); # TODO: org tz + my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org_id); + + my $counter = 0; + while ($counter++ < $day_count) { + + $date->set(hour => '12', minute => '0', second => '0'); + $date->add(days => 1); + my $date_str = $date->strftime('%FT%T%z'); + + $spanset = org_closed_future_spanset($e, $org_id, $date_str) unless $spanset; + + # method_lookup is the preferred way to invoke in-module + # API calls, but in this case method_lookup is an order + # of magnitude slower with high day counts :(. + my $end = org_next_open_day($org_id, $date_str, $spanset, $hoo); + + # No overlap means today is good. If we found an overlap + # push the current test date to the end date of the overlap. + # This new date is also known good. + + if ($end) { + $logger->info("open days calc found an overlap ending at $end"); + $date = DateTime::Format::ISO8601 + ->parse_datetime(cleanse_ISO8601($end)); + } + } + + return $date->strftime('%FT%T%z'); +} + +# Build a spanset of dates starting at 'date' including all future +# closed dates. +sub org_closed_future_spanset { + my ($e, $org_id, $date) = @_; + + $logger->debug( + "Creating org closed dates spanset for $org_id starting $date"); + + my $close_dates = $e->search_actor_org_unit_closed_date( + {org_unit => $org_id, close_end => {'>=' => $date}}); + + return undef unless @$close_dates; + + my $dtp = DateTime::Format::ISO8601->new; + + my $spanset = DateTime::SpanSet->empty_set; + for my $close (@$close_dates) { + my $start = $dtp->parse_datetime(cleanse_ISO8601($close->close_start)); + my $end = $dtp->parse_datetime(cleanse_ISO8601($close->close_end)); + + $spanset = $spanset->union( + DateTime::Span->new(start => $start, end => $end)); + } + + return $spanset; +} + +# Returns a date string representing the next day the selected org +# unit is open, taking both closed dates and hours of operation info +# account. +sub org_next_open_day { + my $org_id = shift; + my $date = shift; + my $closure_spanset = shift; + my $hoo = shift; + my $dtp = DateTime::Format::ISO8601->new; + + $date = cleanse_ISO8601($date); + my $target_date = $dtp->parse_datetime($date); + my ($begin, $end) = ($target_date, $target_date); + + if ($closure_spanset && $closure_spanset->intersects($target_date)) { + my $intersection = $closure_spanset->intersection( $target_date ); + $begin = $intersection->min; + $end = $intersection->max; + + $end->add(days => 1); + while (my $next = org_next_open_day($org_id, + $end->strftime('%FT%T%z'), $closure_spanset, $hoo)) { + $end = $dtp->parse_datetime(cleanse_ISO8601($next)); + } + } + + # Look for the next open hours-of-operation day. If no open day is + # found after 7 attempts, give up to avoid looping on orgs that + # have no open hours of operation. + my $hoo_counter = 0; + my $hoo_date = $end->clone; + while ($hoo_counter++ < 7) { + if (org_operates_on_date($org_id, $hoo_date, $hoo)) { + $end = $hoo_date; + last; + } + $hoo_date->add(days => 1); + } + + if ($hoo_counter == 7) { + # The org unit is closed every day, which means this + # function can never return a meaningful value. + $logger->warn("Org unit $org_id is closed every day"); + return undef; + } + + my $start = $begin->strftime('%FT%T%z'); + my $stop = $end->strftime('%FT%T%z'); + + # If the start and end date match, no date modifications were + # required. Selected date is open. + return undef if ($start eq $stop); + + if ($hoo_counter > 1) { + # End date pushed ahead due to an hours of operation collision. + # Confirm the new date is not a closed date. + + my $final_date = org_next_open_day( + $org_id, $stop, $closure_spanset, $hoo); + + # date was bumped ahead even further. + return $final_date if $final_date; + } + + return $stop; +} + +# Returns true of the org unit has any hours of operation +# for the selected date. +sub org_operates_on_date { + my ($org_id, $date, $hoo) = @_; + + # No dates means always open + return 1 unless $hoo; + + my $dow = $date->day_of_week_0; + my $omethod = "dow_${dow}_open"; + my $cmethod = "dow_${dow}_close"; + + # both values will equal 0 when the org unit is closed. + return $hoo->$omethod() ne $hoo->$cmethod(); +} + 1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm index 1e57e9abd0..c933478b47 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm @@ -1094,6 +1094,7 @@ sub update_hold_impl { sub set_hold_shelf_expire_time { my ($class, $hold, $editor, $start_time) = @_; + # TODO: create org unit setting for explicitly tracking days. my $shelf_expire = $U->ou_ancestor_setting_value( $hold->pickup_lib, 'circ.holds.default_shelf_expire_interval', @@ -1102,31 +1103,28 @@ sub set_hold_shelf_expire_time { return undef unless $shelf_expire; - $start_time = ($start_time) ? - DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($start_time)) : - DateTime->now(time_zone => 'local'); # without time_zone we get UTC ... yuck! - - my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($shelf_expire); - my $expire_time = $start_time->add(seconds => $seconds); + # Grab the numeric interval from the text + # TODO: THIS MAKES ASSUMPTIONS ABOUT THE EXPIRE INTERVAL + # BEING DEFINED IN DAYS. + # TODO: See above about shelf_expire_days setting + my $day_count; + if ($shelf_expire =~ /(\d+)/g) { + $day_count = $1; + } - # if the shelf expire time overlaps with a pickup lib's - # closed date, push it out to the first open date - my $dateinfo = $U->storagereq( - 'open-ils.storage.actor.org_unit.closed_date.overlap', - $hold->pickup_lib, $expire_time->strftime('%FT%T%z')); + return undef unless $day_count; - if($dateinfo) { - my $dt_parser = DateTime::Format::ISO8601->new; - $expire_time = $dt_parser->parse_datetime(clean_ISO8601($dateinfo->{end})); + my $date_str = $U->org_unit_open_days( + $hold->pickup_lib, $day_count, $editor); - # TODO: enable/disable time bump via setting? - $expire_time->set(hour => '23', minute => '59', second => '59'); - - $logger->info("circulator: shelf_expire_time overlaps". - " with closed date, pushing expire time to $expire_time"); - } + my $expire_time = DateTime::Format::ISO8601->new + ->parse_datetime(clean_ISO8601($date_str)); + # Hold shelf expire should include the full final day. + # TODO: enable/disable time bump via setting? + $expire_time->set(hour => '23', minute => '59', second => '59'); $hold->shelf_expire_time($expire_time->strftime('%FT%T%z')); + return undef; } -- 2.11.0