LP1829295 Hold shelf expire date honors closed + hours user/berick/lp1829295-shelf-expire-honors-closed-and-hours
authorBill Erickson <berickxx@gmail.com>
Tue, 27 Mar 2018 15:38:40 +0000 (11:38 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 16 May 2019 15:20:06 +0000 (11:20 -0400)
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 <berickxx@gmail.com>
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/ClosedDates.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm

index fc53c16..9e0b55d 100644 (file)
@@ -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);
+}
 
 
 
index 5ed0e25..0145567 100644 (file)
@@ -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;
 
index 1e57e9a..c933478 100644 (file)
@@ -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;
 }