From: Mike Rylander Date: Tue, 19 May 2020 19:11:54 +0000 (-0400) Subject: Curbside application X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=9ec22cee65f996e5222a9ad67c769df39572e0b7;p=working%2FEvergreen.git Curbside application This commit adds the Curbside OpenSRF application, which provides all the business logic and data retrieval APIs for the UIs. Signed-off-by: Galen Charlton --- diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example index eb875b1116..f9736fd762 100644 --- a/Open-ILS/examples/opensrf.xml.example +++ b/Open-ILS/examples/opensrf.xml.example @@ -1270,6 +1270,26 @@ vim:et:ts=4:sw=4: 60 + + + 5 + 1 + perl + OpenILS::Application::Curbside + 1000 + + curbside_unix.sock + curbside_unix.pid + curbside_unix.log + 1000 + 1 + 15 + 1 + 5 + + + + @@ -1317,6 +1337,7 @@ vim:et:ts=4:sw=4: open-ils.serial open-ils.hold-targeter open-ils.ebook_api + open-ils.curbside diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm new file mode 100644 index 0000000000..3ae817b31e --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm @@ -0,0 +1,723 @@ +package OpenILS::Application::Curbside; + +use strict; +use warnings; + +use POSIX qw/strftime/; +use OpenSRF::AppSession; +use OpenILS::Application; +use base qw/OpenILS::Application/; + +use OpenILS::Utils::DateTime qw/:datetime/; +use OpenILS::Utils::CStoreEditor qw/:funcs/; +use OpenILS::Utils::Fieldmapper; +use OpenILS::Application::AppUtils; +my $U = "OpenILS::Application::AppUtils"; + +use DateTime; +use DateTime::Format::ISO8601; + +my $date_parser = DateTime::Format::ISO8601->new; + +use OpenSRF::Utils::Logger qw/$logger/; + +sub fetch_delivered { # returns appointments delivered TODAY + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + arrival => { '!=' => undef}, + delivered => { '>' => 'today'}, + },{ + flesh => 1, flesh_fields => {acsp => 'patron'}, + order_by => { acsp => {delivered => {direction => 'desc'}} } + }]); + + $conn->respond($_) for @$slots; + return undef; +} +__PACKAGE__->register_method( + method => "fetch_delivered", + api_name => "open-ils.curbside.fetch_delivered", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'A stream of appointments that were delivered today'} + } +); + +sub fetch_latest_delivered { # returns appointments delivered TODAY + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + arrival => { '!=' => undef}, + delivered => { '>' => 'today'}, + },{ + limit => 1, + order_by => { acsp => {delivered => {direction => 'desc'}} } + }]); + + return @$slots ? $$slots[0]->delivered : undef; +} +__PACKAGE__->register_method( + method => "fetch_latest_delivered", + api_name => "open-ils.curbside.fetch_delivered.latest", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'Most recent delivery timestamp from today, or error event'} + } +); + +sub fetch_arrived { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + arrival => { '!=' => undef}, + delivered => undef, + },{ + flesh => 1, flesh_fields => {acsp => 'patron'}, + order_by => { acsp => 'arrival' } + }]); + + for my $s (@$slots) { + my $holds = $e->search_action_hold_request({ + usr => $s->patron->id, + current_shelf_lib => $s->org, + pickup_lib => $s->org, + shelf_time => {'!=' => undef}, + cancel_time => undef, + fulfillment_time => undef + }); + + $conn->respond({slot => $s, holds => $holds}); + } + + return undef; +} +__PACKAGE__->register_method( + method => "fetch_arrived", + api_name => "open-ils.curbside.fetch_arrived", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'A stream of appointments for patrons that have arrived but are not delivered'} + } +); + +sub fetch_latest_arrived { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + arrival => { '!=' => undef}, + delivered => undef, + },{ + limit => 1, order_by => { acsp => { arrival => { direction => 'desc' } } } + }]); + + return @$slots ? $$slots[0]->arrived : undef; +} +__PACKAGE__->register_method( + method => "fetch_latest_arrived", + api_name => "open-ils.curbside.fetch_arrived.latest", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'Most recent arrival time on undelivered appointments'} + } +); + +sub fetch_staged { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + staged => { '!=' => undef}, + arrived => undef + },{ + flesh => 1, flesh_fields => {acsp => 'patron'}, + order_by => { acsp => 'slot' } + }]); + + for my $s (@$slots) { + my $holds = $e->search_action_hold_request({ + usr => $s->patron->id, + current_shelf_lib => $s->org, + pickup_lib => $s->org, + shelf_time => {'!=' => undef}, + cancel_time => undef, + fulfillment_time => undef + }); + + $conn->respond({slot => $s, holds => $holds}); + } + + return undef; +} +__PACKAGE__->register_method( + method => "fetch_staged", + api_name => "open-ils.curbside.fetch_staged", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'A stream of appointments that are staged but patrons have not yet arrived'} + } +); + +sub fetch_latest_staged { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $slots = $e->search_action_curbside([{ + org => $org, + staged => { '!=' => undef}, + arrived => undef + },{ + limit => 1, order_by => { acsp => { slot => { direction => 'desc' } } } + }]); + + return @$slots ? $$slots[0]->staged : undef; +} +__PACKAGE__->register_method( + method => "fetch_latest_staged", + api_name => "open-ils.curbside.fetch_staged.latest", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'Time of most recently staged appointment'} + } +); + +sub fetch_to_be_staged { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes'; + my $gran_seconds = interval_to_seconds($gran); + my $horizon = new DateTime; + $horizon->add(seconds => $gran_seconds * 2); + + my $slots = $e->search_action_curbside([{ + org => $org, + staged => undef, + slot => { '<=' => $horizon->strftime('%FT%T%z') }, + },{ + flesh => 1, flesh_fields => {acsp => 'patron'}, + order_by => { acsp => 'slot' } + }]); + + for my $s (@$slots) { + my $holds = $e->search_action_hold_request({ + usr => $s->patron->id, + current_shelf_lib => $s->org, + pickup_lib => $s->org, + shelf_time => {'!=' => undef}, + cancel_time => undef, + fulfillment_time => undef + }); + + $conn->respond({slot => $s, holds => $holds}); + } + + return undef; +} +__PACKAGE__->register_method( + method => "fetch_to_be_staged", + api_name => "open-ils.curbside.fetch_to_be_staged", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'A stream of appointments that need to be staged'} + } +); + +sub fetch_latest_to_be_staged { + my ($self, $conn, $authtoken, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes'; + my $gran_seconds = interval_to_seconds($gran); + my $horizon = new DateTime; + $horizon->add(seconds => $gran_seconds * 2); + + my $slots = $e->search_action_curbside([{ + org => $org, + staged => undef, + slot => { '<=' => $horizon->strftime('%FT%T%z') }, + },{ + limit => 1, order_by => { acsp => { slot => { direction => 'desc' } } } + }]); + + return @$slots ? $$slots[0]->slot : undef; +} +__PACKAGE__->register_method( + method => "fetch_latest_to_be_staged", + api_name => "open-ils.curbside.fetch_to_be_staged.latest", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Library ID'}, + ], + return => { desc => 'Appointment time of the latest slot that needs to be staged'} + } +); + +sub times_for_date { + my ($self, $conn, $authtoken, $date, $org) = @_; + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + my $start_obj = $date_parser->parse_datetime($date); + return $conn->respond_complete unless ($start_obj); + + my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes'; + my $gran_seconds = interval_to_seconds($gran); + + my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10; + + my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org); + return undef unless ($hoo); + + my $dow = $start_obj->day_of_week_0; + + my $open_method = "dow_${dow}_open"; + my $close_method = "dow_${dow}_close"; + + my $open_time = $hoo->$open_method; + my $close_time = $hoo->$close_method; + return $conn->respond_complete if ($open_time eq $close_time); # location closed that day + + $start_obj = $date_parser->parse_datetime($date.'T'.$open_time); # reset this to opening time + my $end_obj = $date_parser->parse_datetime($date.'T'.$close_time); + + my $now_obj = DateTime->now; + my $step_obj = $start_obj->clone; + while (DateTime->compare($step_obj,$end_obj) <= 0) { # inside HOO + if (DateTime->compare($step_obj,$now_obj) >= 0) { # don't offer times in the past + my $step_ts = $step_obj->strftime('%FT%T%z'); + my $other_slots = $e->search_action_curbside({org => $org, slot => $step_ts}, {idlist => 1}); + my $available = $max - scalar(@$other_slots); + $conn->respond([$step_obj->strftime('%T'), $available]); + } + $step_obj->add(seconds => $gran_seconds); + } + + $e->disconnect; + return undef; +} +__PACKAGE__->register_method( + method => "times_for_date", + api_name => "open-ils.curbside.times_for_date", + argc => 2, + streaming=> 1, + signature=> { + params => [ + {type => "string", desc => "Authentication token"}, + {type => "string", desc => "Date to find times for"}, + {type => "number", desc => "Library ID (default ws_ou)"}, + ], + return => {desc => 'A stream of array refs, structure: ["hh:mm:ss",$available_count]; event on error.'} + }, + notes => 'Restricted to logged in users to avoid spamming induced load' +); + +sub create_update_appointment { + my ($self, $conn, $authtoken, $patron, $date, $time, $org) = @_; + my $mode = 'create'; + $mode = 'update' if ($self->api_name =~ /update/); + + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + $org ||= $e->requestor->ws_ou; + + return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true( + $U->ou_ancestor_setting_value($org, 'circ.curbside') + )); + + unless ($patron == $e->requestor->id) { + return $e->die_event unless $e->allowed("STAFF_LOGIN"); + } + + my $date_obj = $date_parser->parse_datetime($date); + return undef unless ($date_obj); + + my $slot; + + # do they already have an open slot? + # NOTE: once arrived is set, it's past the point of editing. + my $old_slot = $e->search_action_curbside({ + patron => $patron, + org => $org, + slot => { '!=' => undef }, + arrived=> undef + })->[0]; + if ($old_slot) { + if ($mode eq 'create') { + my $ev = new OpenILS::Event("CURBSIDE_EXISTS"); + $e->disconnect; + return $ev; + } else { + $slot = $old_slot; + } + } + + + my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes'; + my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10; + + # some sanity checking + my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org); + return undef unless ($hoo); + + my $dow = $date_obj->day_of_week_0; + + my $open_method = "dow_${dow}_open"; + my $close_method = "dow_${dow}_close"; + + my $open_time = $hoo->$open_method; + my $close_time = $hoo->$close_method; + return undef if ($open_time eq $close_time); # location closed that day + + my $open_seconds = interval_to_seconds($open_time); + my $close_seconds = interval_to_seconds($close_time); + + my $time_seconds = interval_to_seconds($time); + my $gran_seconds = interval_to_seconds($gran); + + return undef if ($time_seconds < $open_seconds); # too early + return undef if ($time_seconds > $close_seconds + 1); # too late (/at/ closing allowed) + + $date_obj = $date_parser->parse_datetime($date.'T'.$open_time); + + my $time_into_open_second = $time - $open_time; + if (my $extra_time = $time_into_open_second % $gran) { # a remainder means we got a time we shouldn't have + $time_into_open_second -= $extra_time; # just back it off to have staff gather earlier + } + + my $slot_ts = $date_obj->add(seconds => $time_into_open_second)->strftime('%FT%T%z'); + + # finally, confirm that there aren't too many already + my $other_slots = $e->search_action_curbside({org => $org, slot => $slot_ts}, {idlist => 1}); + if (scalar(@$other_slots) >= $max) { # oops... return error + my $ev = new OpenILS::Event("CURBSIDE_MAX_FOR_TIME"); + $e->disconnect; + return $ev; + } + + my $method = 'update_action_curbside'; + if ($mode eq 'create' or !$slot) { + $slot = $e->search_action_curbside({ + patron => $patron, + org => $org, + slot => undef, + arrived=> undef, + })->[0]; + } + + if (!$slot) { # just in case the hold-ready reactor isn't in place + $slot = Fieldmapper::action::curbside->new; + $slot->patron($patron); + $slot->org($org); + $method = 'create_action_curbside'; + } + + $slot->slot($slot_ts); + $slot = $e->$method($slot); + + $e->commit; + $conn->respond_complete($slot); + + OpenSRF::AppSession + ->create('open-ils.trigger') + ->request( + 'open-ils.trigger.event.autocreate', + 'hold.confirm_curbside', + $slot, $slot->org); + + return undef; +} +__PACKAGE__->register_method( + method => "create_update_appointment", + api_name => "open-ils.curbside.update_appointment", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Patron ID'}, + {type => 'string', desc => 'New Date'}, + {type => 'string', desc => 'New Time'}, + {type => 'number', desc => 'Library ID (default ws_ou)'}, + ], + return => { desc => 'An action::curbside record on success, '. + 'an ILS Event on config, permission, or '. + 'recoverable errors, or nothing on bad '. + 'or silly data'} + } +); + +__PACKAGE__->register_method( + method => "create_update_appointment", + api_name => "open-ils.curbside.create_appointment", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Patron ID'}, + {type => 'string', desc => 'Date'}, + {type => 'string', desc => 'Time'}, + {type => 'number', desc => 'Library ID (default ws_ou)'}, + ], + return => { desc => 'An action::curbside record on success, '. + 'an ILS Event on config, permission, or '. + 'recoverable errors, or nothing on bad '. + 'or silly data'} + } +); + +sub delete_appointment { + my ($self, $conn, $authtoken, $appointment) = @_; + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + my $slot = $e->retrieve_action_curbside($appointment); + return undef unless ($slot); + + unless ($appointment->patron == $e->requestor->id) { + return $e->die_event unless $e->allowed("STAFF_LOGIN"); + } + + $e->delete_action_curbside($slot) or return $e->die_event; + $e->commit; + + return undef; +} +__PACKAGE__->register_method( + method => "delete_appointment", + api_name => "open-ils.curbside.delete_appointment", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Patron'}, + {type => 'string', desc => 'Date'}, + {type => 'string', desc => 'Time'}, + ], + return => { desc => 'Nothing on success or no appointment found'. + 'an ILS Event on permission error'} + } +); + +sub mark_staged { + my ($self, $conn, $authtoken, $appointment) = @_; + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + return $e->die_event unless $e->allowed("STAFF_LOGIN"); + + my $slot = $e->retrieve_action_curbside($appointment); + return undef unless ($slot); + + $slot->staged('now'); + $slot->stage_staff($e->requestor->id); + $slot = $e->update_action_curbside($slot) or return $e->die_event; + $e->commit; + + return $slot; +} +__PACKAGE__->register_method( + method => "mark_staged", + api_name => "open-ils.curbside.mark_staged", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Appointment ID'}, + ], + return => { desc => 'Appointment on success, nothing when no appointment found, '. + 'an ILS Event on permission error'} + } +); + +sub mark_arrived { + my ($self, $conn, $authtoken, $appointment) = @_; + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + + my $slot = $e->retrieve_action_curbside($appointment); + return undef unless ($slot); + + unless ($appointment->patron == $e->requestor->id) { + return $e->die_event unless $e->allowed("STAFF_LOGIN"); + } + + $slot->arrival('now'); + + $slot = $e->update_action_curbside($slot) or return $e->die_event; + $e->commit; + + return $slot; +} +__PACKAGE__->register_method( + method => "mark_arrived", + api_name => "open-ils.curbside.mark_arrived", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Appointment ID'}, + ], + return => { desc => 'Nothing on success or no appointment found'. + 'an ILS Event on permission error'} + } +); + +sub mark_delivered { + my ($self, $conn, $authtoken, $appointment) = @_; + my $e = new_editor(xact => 1, authtoken => $authtoken); + return $e->die_event unless $e->checkauth; + return $e->die_event unless $e->allowed("STAFF_LOGIN"); + + my $slot = $e->retrieve_action_curbside($appointment); + return undef unless ($slot); + + if (!$slot->staged) { + $slot->staged('now'); + $slot->stage_staff($e->requestor->id); + } + + if (!$slot->arrival) { + $slot->arrival('now'); + } + + $slot->delivered('now'); + $slot->delivery_staff($e->requestor->id); + + $slot = $e->update_action_curbside($slot) or return $e->die_event; + $e->commit; + + my $holds = $e->search_action_hold_request({ + usr => $slot->patron, + current_shelf_lib => $slot->org, + pickup_lib => $slot->org, + shelf_time => {'!=' => undef}, + cancel_time => undef, + fulfillment_time => undef + }); + + my $circ_sess = OpenSRF::AppSession->connect('open-ils.circ'); + my @requests = map { + $circ_sess->request( # Just try as hard as possible to check out everything + 'open-ils.circ.checkout.full.override', + $authtoken, { patron => $slot->patron, copyid => $_->current_copy } + ) + } @$holds; + + $conn->respond($_->gather(1)) for @requests; + $circ_sess->disconnect; + + return $slot; +} +__PACKAGE__->register_method( + method => "mark_delivered", + api_name => "open-ils.curbside.mark_delivered", + signature => { + params => [ + {type => 'string', desc => 'Authentication token'}, + {type => 'number', desc => 'Appointment ID'}, + ], + return => { desc => 'Nothing for no appointment found, '. + 'a stream of open-ils.circ.checkout.full.override '. + 'responses followed by the finalized slot, '. + 'or an ILS Event on permission error'} + } +); + +1;