]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Curbside.pm
lp1863252 toward geosort
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Curbside.pm
1 package OpenILS::Application::Curbside;
2
3 use strict;
4 use warnings;
5
6 use POSIX qw/strftime/;
7 use OpenSRF::AppSession;
8 use OpenILS::Application;
9 use base qw/OpenILS::Application/;
10
11 use OpenILS::Utils::DateTime qw/:datetime/;
12 use OpenILS::Utils::CStoreEditor qw/:funcs/;
13 use OpenILS::Utils::Fieldmapper;
14 use OpenILS::Application::AppUtils;
15 my $U = "OpenILS::Application::AppUtils";
16
17 use Digest::MD5 qw(md5_hex);
18
19 use DateTime;
20 use DateTime::Format::ISO8601;
21
22 my $date_parser = DateTime::Format::ISO8601->new;
23
24 use OpenSRF::Utils::Logger qw/$logger/;
25
26 sub fetch_mine { # returns appointments owned by $authtoken user, optional $org filter
27     my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
28
29     my $e = new_editor(xact => 1, authtoken => $authtoken);
30     return $e->die_event unless $e->checkauth;
31
32     # NOTE: not checking if curbside is enabled here
33     # because the pickup lib might be anything
34
35     my $slots = $e->search_action_curbside([{
36         patron    => $e->requestor->id,
37         delivered => { '=' => undef },
38         ( $org ? (org => $org) : () )
39     },{
40         ($limit  ? (limit  => $limit) : ()),
41         ($offset ? (offset => $offset) : ()),
42         flesh => 2, flesh_fields => {acsp => ['patron'], au => ['card']},
43         order_by => { acsp => {slot => {direction => 'asc'}} }
44     }]);
45
46     $conn->respond($_) for @$slots;
47     return undef;
48 }
49 __PACKAGE__->register_method(
50     method   => "fetch_mine",
51     api_name => "open-ils.curbside.fetch_mine",
52     stream   => 1,
53     signature => {
54         params => [
55             {type => 'string', desc => 'Authentication token'},
56             {type => 'number', desc => 'Optional Library ID to filter further'},
57             {type => 'number', desc => 'Fetch limit'},
58             {type => 'number', desc => 'Fetch offset'},
59         ],
60         return => { desc => 'A stream of appointments that the authenticated user owns'}
61     }
62 );
63
64 sub fetch_appointments { # returns appointment for user at location
65     my ($self, $conn, $authtoken, $usr, $org) = @_;
66
67     my $e = new_editor(xact => 1, authtoken => $authtoken);
68     return $e->die_event unless $e->checkauth;
69
70     $org ||= $e->requestor->ws_ou;
71
72     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
73         $U->ou_ancestor_setting_value($org, 'circ.curbside')
74     ));
75
76     return new OpenILS::Event("BAD_PARAMS", "desc" => "No user ID supplied") unless $usr;
77
78     unless ($usr == $e->requestor->id) {
79         return $e->die_event unless $e->allowed("STAFF_LOGIN");
80     }
81
82     my $slots = $e->search_action_curbside([{
83         patron    => $usr,
84         delivered => { '=' => undef },
85         org       => $org,
86     },{
87         order_by => { acsp => {slot => {direction => 'asc'}} }
88     }]);
89
90     $conn->respond($_) for @$slots;
91     return undef;
92 }
93 __PACKAGE__->register_method(
94     method   => "fetch_appointments",
95     api_name => "open-ils.curbside.open_user_appointments_at_lib",
96     stream   => 1,
97     signature => {
98         params => [
99             {type => 'string', desc => 'Authentication token'},
100             {type => 'number', desc => 'Patron ID'},
101             {type => 'number', desc => 'Optional pickup library. If not supplied, taken from WS of session.'},
102         ],
103         return => { desc => 'A stream of appointments that the authenticated user owns'}
104     }
105 );
106
107 sub fetch_holds_for_patron_at_pickup_lib {
108     my ($self, $conn, $authtoken, $usr, $org) = @_;
109
110     my $e = new_editor(xact => 1, authtoken => $authtoken);
111     return $e->die_event unless $e->checkauth;
112
113     $org ||= $e->requestor->ws_ou;
114
115     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
116         $U->ou_ancestor_setting_value($org, 'circ.curbside')
117     ));
118
119     return new OpenILS::Event("BAD_PARAMS", "desc" => "No user ID supplied") unless $usr;
120
121     my $holds = $e->search_action_hold_request({
122         usr => $usr,
123         current_shelf_lib => $org,
124         pickup_lib => $org,
125         shelf_time => {'!=' => undef},
126         cancel_time => undef,
127         fulfillment_time => undef
128     }, { idlist => 1 });
129
130     return scalar(@$holds);
131
132 }
133 __PACKAGE__->register_method(
134     method   => "fetch_holds_for_patron_at_pickup_lib",
135     api_name => "open-ils.curbside.patron.ready_holds_at_lib.count",
136     signature => {
137         params => [
138             {type => 'string', desc => 'Authentication token'},
139             {type => 'number', desc => 'Patron ID'},
140             {type => 'number', desc => 'Optional pickup library. If not supplied, taken from WS of session.'},
141         ],
142         return => { desc => 'Number of holds on the shelf for the patron at the specified library'}
143     }
144 );
145
146 sub _flesh_and_emit_slots {
147     my ($conn, $e, $slots) = @_;
148
149     for my $s (@$slots) {
150         my $start_time;
151         my $end_time;
152         if ($s->delivered) {
153             $start_time = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($s->delivered));
154             $end_time = $start_time->clone->add(seconds => 90); # 90 seconds is arbitrary
155         }
156         my $holds = $e->search_action_hold_request([{
157             usr => $s->patron->id,
158             current_shelf_lib => $s->org,
159             pickup_lib => $s->org,
160             shelf_time => {'!=' => undef},
161             cancel_time => undef,
162             ($s->delivered) ?
163                 (
164                     '-and' => [ { fulfillment_time => {'>=' => $start_time->strftime('%FT%T%z') } },
165                                 { fulfillment_time => {'<=' => $end_time->strftime('%FT%T%z') } } ],
166                 ) :
167                 (fulfillment_time => undef),
168         },{
169             flesh => 1, flesh_fields => {ahr => ['current_copy']},
170         }]);
171
172         my $rhrr_list = $e->search_reporter_hold_request_record(
173             {id => [ map { $_->id } @$holds ]}
174         );
175
176         my %bib_data = map {
177             ($_->id => $e->retrieve_metabib_wide_display_entry( $_->bib_record))
178         } @$rhrr_list;
179
180         $conn->respond({slot_id => $s->id, slot => $s, holds => $holds, bib_data_by_hold => \%bib_data});
181     }
182 }
183
184 sub fetch_delivered { # returns appointments delivered TODAY
185     my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
186
187     my $e = new_editor(xact => 1, authtoken => $authtoken);
188     return $e->die_event unless $e->checkauth;
189
190     $org ||= $e->requestor->ws_ou;
191
192     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
193         $U->ou_ancestor_setting_value($org, 'circ.curbside')
194     ));
195
196     my $slots = $e->search_action_curbside([{
197         org => $org,
198         arrival => { '!=' => undef},
199         delivered => { '>' => 'today'},
200     },{
201         ($limit  ? (limit  => $limit) : ()),
202         ($offset ? (offset => $offset) : ()),
203         flesh => 2, flesh_fields => {acsp => ['patron'], au => ['card']},
204         order_by => { acsp => {delivered => {direction => 'desc'}} }
205     }]);
206
207     _flesh_and_emit_slots($conn, $e, $slots);
208
209     return undef;
210 }
211 __PACKAGE__->register_method(
212     method   => "fetch_delivered",
213     api_name => "open-ils.curbside.fetch_delivered",
214     stream   => 1,
215     signature => {
216         params => [
217             {type => 'string', desc => 'Authentication token'},
218             {type => 'number', desc => 'Library ID'},
219             {type => 'number', desc => 'Fetch limit'},
220             {type => 'number', desc => 'Fetch offset'},
221         ],
222         return => { desc => 'A stream of appointments that were delivered today'}
223     }
224 );
225
226 sub fetch_latest_delivered { # returns appointments delivered TODAY
227     my ($self, $conn, $authtoken, $org) = @_;
228
229     my $e = new_editor(xact => 1, authtoken => $authtoken);
230     return $e->die_event unless $e->checkauth;
231
232     $org ||= $e->requestor->ws_ou;
233
234     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
235         $U->ou_ancestor_setting_value($org, 'circ.curbside')
236     ));
237
238     my $slots = $e->search_action_curbside([{
239         org => $org,
240         arrival => { '!=' => undef},
241         delivered => { '>' => 'today'},
242     },{
243         order_by => { acsp => {delivered => {direction => 'desc'}} }
244     }],{ idlist => 1 });
245
246     return md5_hex( join(',', @$slots) );
247 }
248 __PACKAGE__->register_method(
249     method   => "fetch_latest_delivered",
250     api_name => "open-ils.curbside.fetch_delivered.latest",
251     signature => {
252         params => [
253             {type => 'string', desc => 'Authentication token'},
254             {type => 'number', desc => 'Library ID'},
255         ],
256         return => { desc => 'Hash of appointment IDs delivered today, or error event'}
257     }
258 );
259
260 sub fetch_arrived {
261     my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
262
263     my $e = new_editor(xact => 1, authtoken => $authtoken);
264     return $e->die_event unless $e->checkauth;
265
266     $org ||= $e->requestor->ws_ou;
267
268     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
269         $U->ou_ancestor_setting_value($org, 'circ.curbside')
270     ));
271
272     my $slots = $e->search_action_curbside([{
273         org => $org,
274         arrival => { '!=' => undef},
275         delivered => undef,
276     },{
277         ($limit  ? (limit  => $limit) : ()),
278         ($offset ? (offset => $offset) : ()),
279         flesh => 3, flesh_fields => {acsp => ['patron'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
280         order_by => { acsp => 'arrival' }
281     }]);
282
283
284     _flesh_and_emit_slots($conn, $e, $slots);
285
286     return undef;
287 }
288 __PACKAGE__->register_method(
289     method   => "fetch_arrived",
290     api_name => "open-ils.curbside.fetch_arrived",
291     stream   => 1,
292     signature => {
293         params => [
294             {type => 'string', desc => 'Authentication token'},
295             {type => 'number', desc => 'Library ID'},
296             {type => 'number', desc => 'Fetch limit'},
297             {type => 'number', desc => 'Fetch offset'},
298         ],
299         return => { desc => 'A stream of appointments for patrons that have arrived but are not delivered'}
300     }
301 );
302
303 sub fetch_latest_arrived {
304     my ($self, $conn, $authtoken, $org) = @_;
305
306     my $e = new_editor(xact => 1, authtoken => $authtoken);
307     return $e->die_event unless $e->checkauth;
308
309     $org ||= $e->requestor->ws_ou;
310
311     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
312         $U->ou_ancestor_setting_value($org, 'circ.curbside')
313     ));
314
315     my $slots = $e->search_action_curbside([{
316         org => $org,
317         arrival => { '!=' => undef},
318         delivered => undef,
319     },{
320         order_by => { acsp => { arrival => { direction => 'desc' } } }
321     }],{ idlist => 1 });
322
323     return md5_hex( join(',', @$slots) );
324 }
325 __PACKAGE__->register_method(
326     method   => "fetch_latest_arrived",
327     api_name => "open-ils.curbside.fetch_arrived.latest",
328     signature => {
329         params => [
330             {type => 'string', desc => 'Authentication token'},
331             {type => 'number', desc => 'Library ID'},
332         ],
333         return => { desc => 'Hash of appointment IDs for undelivered appointments'}
334     }
335 );
336
337 sub fetch_staged {
338     my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
339
340     my $e = new_editor(xact => 1, authtoken => $authtoken);
341     return $e->die_event unless $e->checkauth;
342
343     $org ||= $e->requestor->ws_ou;
344
345     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
346         $U->ou_ancestor_setting_value($org, 'circ.curbside')
347     ));
348
349     my $slots = $e->search_action_curbside([{
350         org => $org,
351         staged => { '!=' => undef},
352         arrival => undef
353     },{
354         ($limit  ? (limit  => $limit) : ()),
355         ($offset ? (offset => $offset) : ()),
356         flesh => 3, flesh_fields => {acsp => ['patron'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
357         order_by => { acsp => 'slot' }
358     }]);
359
360     _flesh_and_emit_slots($conn, $e, $slots);
361
362     return undef;
363 }
364 __PACKAGE__->register_method(
365     method   => "fetch_staged",
366     api_name => "open-ils.curbside.fetch_staged",
367     stream   => 1,
368     signature => {
369         params => [
370             {type => 'string', desc => 'Authentication token'},
371             {type => 'number', desc => 'Library ID'},
372             {type => 'number', desc => 'Fetch limit'},
373             {type => 'number', desc => 'Fetch offset'},
374         ],
375         return => { desc => 'A stream of appointments that are staged but patrons have not yet arrived'}
376     }
377 );
378
379 sub fetch_latest_staged {
380     my ($self, $conn, $authtoken, $org) = @_;
381
382     my $e = new_editor(xact => 1, authtoken => $authtoken);
383     return $e->die_event unless $e->checkauth;
384
385     $org ||= $e->requestor->ws_ou;
386
387     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
388         $U->ou_ancestor_setting_value($org, 'circ.curbside')
389     ));
390
391     my $slots = $e->search_action_curbside([{
392         org => $org,
393         staged => { '!=' => undef},
394         arrival => undef
395     },{
396         order_by => [
397             { class => acsp => field => slot => direction => 'desc' },
398             { class => acsp => field => id   => direction => 'desc' }
399         ]
400     }],{ idlist => 1 });
401
402     return md5_hex( join(',', @$slots) );
403 }
404 __PACKAGE__->register_method(
405     method   => "fetch_latest_staged",
406     api_name => "open-ils.curbside.fetch_staged.latest",
407     signature => {
408         params => [
409             {type => 'string', desc => 'Authentication token'},
410             {type => 'number', desc => 'Library ID'},
411         ],
412         return => { desc => 'Hash of appointment IDs for staged appointment'}
413     }
414 );
415
416 sub fetch_to_be_staged {
417     my ($self, $conn, $authtoken, $org, $limit, $offset) = @_;
418
419     my $e = new_editor(xact => 1, authtoken => $authtoken);
420     return $e->die_event unless $e->checkauth;
421
422     $org ||= $e->requestor->ws_ou;
423
424     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
425         $U->ou_ancestor_setting_value($org, 'circ.curbside')
426     ));
427
428     my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
429     my $gran_seconds = interval_to_seconds($gran);
430     my $horizon = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
431     $horizon->add(seconds => $gran_seconds * 2);
432
433     my $slots = $e->search_action_curbside([{
434         org => $org,
435         staged => undef,
436         slot => { '<=' => $horizon->strftime('%FT%T%z') },
437     },{
438         ($limit  ? (limit  => $limit) : ()),
439         ($offset ? (offset => $offset) : ()),
440         flesh => 3, flesh_fields => {acsp => ['patron','stage_staff'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
441         order_by => { acsp => 'slot' }
442     }]);
443
444     _flesh_and_emit_slots($conn, $e, $slots);
445
446     return undef;
447 }
448 __PACKAGE__->register_method(
449     method   => "fetch_to_be_staged",
450     api_name => "open-ils.curbside.fetch_to_be_staged",
451     stream   => 1,
452     signature => {
453         params => [
454             {type => 'string', desc => 'Authentication token'},
455             {type => 'number', desc => 'Library ID'},
456             {type => 'number', desc => 'Fetch limit'},
457             {type => 'number', desc => 'Fetch offset'},
458         ],
459         return => { desc => 'A stream of appointments that need to be staged'}
460     }
461 );
462
463 sub fetch_latest_to_be_staged {
464     my ($self, $conn, $authtoken, $org) = @_;
465
466     my $e = new_editor(xact => 1, authtoken => $authtoken);
467     return $e->die_event unless $e->checkauth;
468
469     $org ||= $e->requestor->ws_ou;
470
471     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
472         $U->ou_ancestor_setting_value($org, 'circ.curbside')
473     ));
474
475     my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
476     my $gran_seconds = interval_to_seconds($gran);
477     my $horizon = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
478     $horizon->add(seconds => $gran_seconds * 2);
479
480     my $slots = $e->search_action_curbside([{
481         org => $org,
482         staged => undef,
483         slot => { '<=' => $horizon->strftime('%FT%T%z') },
484     },{
485         order_by => [
486             { class => acsp => field => slot => direction => 'desc' },
487             { class => acsp => field => id   => direction => 'desc' }
488         ]
489     }]);
490
491     return md5_hex( join(',', map { join('-', $_->id(), $_->stage_staff() // '', $_->arrival() // '') } @$slots) );
492 }
493 __PACKAGE__->register_method(
494     method   => "fetch_latest_to_be_staged",
495     api_name => "open-ils.curbside.fetch_to_be_staged.latest",
496     signature => {
497         params => [
498             {type => 'string', desc => 'Authentication token'},
499             {type => 'number', desc => 'Library ID'},
500         ],
501         return => { desc => 'Hash of appointment IDs that needs to be staged'}
502     }
503 );
504
505 sub times_for_date {
506     my ($self, $conn, $authtoken, $date, $org) = @_;
507
508     my $e = new_editor(xact => 1, authtoken => $authtoken);
509     return $e->die_event unless $e->checkauth;
510
511     $org ||= $e->requestor->ws_ou;
512
513     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
514         $U->ou_ancestor_setting_value($org, 'circ.curbside')
515     ));
516
517     my $start_obj = $date_parser->parse_datetime($date);
518     return $conn->respond_complete unless ($start_obj);
519
520     my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
521     $gran .= ' minutes' if ($gran =~ /^\s*\d+\s*$/); # Assume minutes for bare numbers (maybe surrounded by spaces)
522
523     my $gran_seconds = interval_to_seconds($gran);
524     $gran_seconds = 600 if ($gran_seconds < 600); # No smaller than 10 minute intervals
525
526     my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10;
527
528     my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org);
529     return undef unless ($hoo);
530
531     my $dow = $start_obj->day_of_week_0;
532
533     my $open_method = "dow_${dow}_open";
534     my $close_method = "dow_${dow}_close";
535
536     my $open_time = $hoo->$open_method;
537     my $close_time = $hoo->$close_method;
538     return $conn->respond_complete if ($open_time eq $close_time); # location closed that day
539
540     my $tz = $U->ou_ancestor_setting_value($org, 'lib.timezone') || 'local';
541     $start_obj = $date_parser->parse_datetime($date.'T'.$open_time)->set_time_zone($tz); # reset this to opening time
542     my $end_obj = $date_parser->parse_datetime($date.'T'.$close_time)->set_time_zone($tz);
543
544     my $now_obj = DateTime->now; # NOTE: does not need timezone set because it gets UTC, not floating, so we can math with it
545     # Add two step intervals to avoid having an appointment be scheduled
546     # sooner than the library could stage the items. Setting the earliest
547     # available time to be no earlier than two intervals from now
548     # is arbitrary and could be made configurable in the future, though
549     # it does follow the hard-coding of the horizon in fetch_to_be_staged().
550     $now_obj->add(seconds => 2 * $gran_seconds);
551
552     my $closings = [];
553     my $step_obj = $start_obj->clone;
554     while (DateTime->compare($step_obj,$end_obj) < 0) { # inside HOO
555         if (DateTime->compare($step_obj,$now_obj) >= 0) { # only offer times in the future
556             my $step_ts = $step_obj->strftime('%FT%T%z');
557
558             if (!@$closings) { # Look for closings that include this slot time.
559                 $closings = $e->search_actor_org_unit_closed_date(
560                     {org_unit => $org, close_start => {'<=' => $step_ts }, close_end => {'>=' => $step_ts }}
561                 );
562             }
563
564             my $skip = 0;
565             for my $closing (@$closings) {
566                 # If we have closings, we check that we're still inside at least one of them.
567                 # If we /are/ inside one then we just move on. Otherwise, we'll forget
568                 # them and check for closings with the next slot time.
569                 if (DateTime->compare($step_obj,$date_parser->parse_datetime(clean_ISO8601($closing->close_end))->set_time_zone($tz)) < 0) {
570                     $step_obj->add(seconds => $gran_seconds);
571                     $skip++;
572                     last;
573                 }
574             }
575             next if $skip;
576             $closings = [];
577
578             my $other_slots = $e->search_action_curbside({org => $org, slot => $step_ts}, {idlist => 1});
579             my $available = $max - scalar(@$other_slots);
580             $available = $available < 0 ? 0 : $available; # so truthiness testing is always easy in the client
581
582             $conn->respond([$step_obj->strftime('%T'), $available]);
583         }
584         $step_obj->add(seconds => $gran_seconds);
585     }
586
587     $e->disconnect;
588     return undef;
589 }
590 __PACKAGE__->register_method(
591     method   => "times_for_date",
592     api_name => "open-ils.curbside.times_for_date",
593     stream   => 1,
594     argc     => 2,
595     signature=> {
596         params => [
597             {type => "string", desc => "Authentication token"},
598             {type => "string", desc => "Date to find times for"},
599             {type => "number", desc => "Library ID (default ws_ou)"},
600         ],
601         return => {desc => 'A stream of array refs, structure: ["hh:mm:ss",$available_count]; event on error.'}
602     },
603     notes   => 'Restricted to logged in users to avoid spamming induced load'
604 );
605
606 sub create_update_appointment {
607     my ($self, $conn, $authtoken, $patron, $date, $time, $org, $notes) = @_;
608     my $mode = 'create';
609     $mode = 'update' if ($self->api_name =~ /update/);
610
611     my $e = new_editor(xact => 1, authtoken => $authtoken);
612     return $e->die_event unless $e->checkauth;
613
614     $org ||= $e->requestor->ws_ou;
615
616     return new OpenILS::Event("CURBSIDE_NOT_ALLOWED") unless ($U->is_true(
617         $U->ou_ancestor_setting_value($org, 'circ.curbside')
618     ));
619
620     unless ($patron == $e->requestor->id) {
621         return $e->die_event unless $e->allowed("STAFF_LOGIN");
622     }
623
624     my $date_obj = $date_parser->parse_datetime($date); # no TZ necessary, just using it to test the input and get DOW
625     return undef unless ($date_obj);
626
627     if ($time =~ /^\d\d:\d\d$/) {
628         $time .= ":00"; # tack on seconds if needed to keep
629                         # interval_to_seconds happy
630     }
631
632     my $slot;
633
634     # do they already have an open slot?
635     # NOTE: once arrival is set, it's past the point of editing.
636     my $old_slot = $e->search_action_curbside({
637         patron  => $patron,
638         org     => $org,
639         slot    => { '!=' => undef },
640         arrival => undef
641     })->[0];
642     if ($old_slot) {
643         if ($mode eq 'create') {
644             my $ev = new OpenILS::Event("CURBSIDE_EXISTS");
645             $e->disconnect;
646             return $ev;
647         } else {
648             $slot = $old_slot;
649         }
650     }
651
652     my $gran = $U->ou_ancestor_setting_value($org, 'circ.curbside.granularity') || '15 minutes';
653     my $max = $U->ou_ancestor_setting_value($org, 'circ.curbside.max_concurrent') || 10;
654
655     # some sanity checking
656     my $hoo = $e->retrieve_actor_org_unit_hours_of_operation($org);
657     return undef unless ($hoo);
658
659     my $dow = $date_obj->day_of_week_0;
660
661     my $open_method = "dow_${dow}_open";
662     my $close_method = "dow_${dow}_close";
663
664     my $open_time = $hoo->$open_method;
665     my $close_time = $hoo->$close_method;
666     return undef if ($open_time eq $close_time); # location closed that day
667
668     my $open_seconds = interval_to_seconds($open_time);
669     my $close_seconds = interval_to_seconds($close_time);
670
671     my $time_seconds = interval_to_seconds($time);
672     my $gran_seconds = interval_to_seconds($gran);
673
674     return undef if ($time_seconds < $open_seconds); # too early
675     return undef if ($time_seconds > $close_seconds + 1); # too late (/at/ closing allowed)
676
677     my $time_into_open_second = $time_seconds - $open_seconds;
678     if (my $extra_time = $time_into_open_second % $gran) { # a remainder means we got a time we shouldn't have
679         $time_into_open_second -= $extra_time; # just back it off to have staff gather earlier
680     }
681
682     my $tz = $U->ou_ancestor_setting_value($org, 'lib.timezone') || 'local';
683     $date_obj = $date_parser->parse_datetime($date.'T'.$open_time)->set_time_zone($tz);
684
685     my $slot_ts = $date_obj->add(seconds => $time_into_open_second)->strftime('%FT%T%z');
686
687     # finally, confirm that there aren't too many already
688     my $other_slots = $e->search_action_curbside(
689         { org => $org,
690           slot => $slot_ts,
691           ( $slot ? (id => { '<>' => $slot->id }) : () ) # exclude our own slot from the count
692         },
693         {idlist => 1}
694     );
695     if (scalar(@$other_slots) >= $max) { # oops... return error
696         my $ev = new OpenILS::Event("CURBSIDE_MAX_FOR_TIME");
697         $e->disconnect;
698         return $ev;
699     }
700
701     my $method = 'update_action_curbside';
702     if ($mode eq 'create' or !$slot) {
703         $slot = $e->search_action_curbside({
704             patron  => $patron,
705             org     => $org,
706             slot    => undef,
707             arrival => undef,
708         })->[0];
709     }
710
711     if (!$slot) { # just in case the hold-ready reactor isn't in place
712         $slot = Fieldmapper::action::curbside->new;
713         $slot->isnew(1);
714         $slot->patron($patron);
715         $slot->org($org);
716         $slot->notes($notes) if ($notes);
717         $method = 'create_action_curbside';
718     } else {
719         $slot->notes($notes) if ($notes);
720         $slot->ischanged(1);
721         $method = 'update_action_curbside';
722     }
723
724     $slot->slot($slot_ts);
725     $e->$method($slot) or return $e->die_event;
726
727     $e->commit;
728     $conn->respond_complete($e->retrieve_action_curbside($slot->id));
729
730     OpenSRF::AppSession
731         ->create('open-ils.trigger')
732         ->request(
733             'open-ils.trigger.event.autocreate',
734             'hold.confirm_curbside',
735             $slot, $slot->org);
736
737     return undef;
738 }
739 __PACKAGE__->register_method(
740     method   => "create_update_appointment",
741     api_name => "open-ils.curbside.update_appointment",
742     signature => {
743         params => [
744             {type => 'string', desc => 'Authentication token'},
745             {type => 'number', desc => 'Patron ID'},
746             {type => 'string', desc => 'New Date'},
747             {type => 'string', desc => 'New Time'},
748             {type => 'number', desc => 'Library ID (default ws_ou)'},
749         ],
750         return => { desc => 'An action::curbside record on success, '.
751                             'an ILS Event on config, permission, or '.
752                             'recoverable errors, or nothing on bad '.
753                             'or silly data'}
754     }
755 );
756
757 __PACKAGE__->register_method(
758     method   => "create_update_appointment",
759     api_name => "open-ils.curbside.create_appointment",
760     signature => {
761         params => [
762             {type => 'string', desc => 'Authentication token'},
763             {type => 'number', desc => 'Patron ID'},
764             {type => 'string', desc => 'Date'},
765             {type => 'string', desc => 'Time'},
766             {type => 'number', desc => 'Library ID (default ws_ou)'},
767         ],
768         return => { desc => 'An action::curbside record on success, '.
769                             'an ILS Event on config, permission, or '.
770                             'recoverable errors, or nothing on bad '.
771                             'or silly data'}
772     }
773 );
774
775 sub delete_appointment {
776     my ($self, $conn, $authtoken, $appointment) = @_;
777     my $e = new_editor(xact => 1, authtoken => $authtoken);
778     return $e->die_event unless $e->checkauth;
779
780     my $slot = $e->retrieve_action_curbside($appointment);
781     return undef unless ($slot);
782
783     unless ($slot->patron == $e->requestor->id) {
784         return $e->die_event unless $e->allowed("STAFF_LOGIN");
785     }
786
787     $e->delete_action_curbside($slot) or return $e->die_event;
788     $e->commit;
789
790     return -1;
791 }
792 __PACKAGE__->register_method(
793     method   => "delete_appointment",
794     api_name => "open-ils.curbside.delete_appointment",
795     signature => {
796         params => [
797             {type => 'string', desc => 'Authentication token'},
798             {type => 'number', desc => 'Appointment ID'},
799         ],
800         return => { desc => '-1 on success, nothing when no appointment found, '.
801                             'or an ILS Event on permission error'}
802     }
803 );
804
805 sub manage_staging_claim {
806     my ($self, $conn, $authtoken, $appointment) = @_;
807     my $e = new_editor(xact => 1, authtoken => $authtoken);
808     return $e->die_event unless $e->checkauth;
809     return $e->die_event unless $e->allowed("STAFF_LOGIN");
810
811     my $slot = $e->retrieve_action_curbside($appointment);
812     return undef unless ($slot);
813
814     if ($self->api_name =~ /unclaim/) {
815         $slot->clear_stage_staff();
816     } else {
817         $slot->stage_staff($e->requestor->id);
818     }
819
820     $e->update_action_curbside($slot) or return $e->die_event;
821     $e->commit;
822
823     return $e->retrieve_action_curbside([
824         $slot->id, {
825             flesh => 3,
826             flesh_fields => {acsp => ['patron','stage_staff'], au => ['card','standing_penalties'], ausp => ['standing_penalty']},
827         }
828     ]);
829 }
830 __PACKAGE__->register_method(
831     method   => "manage_staging_claim",
832     api_name => "open-ils.curbside.claim_staging",
833     signature => {
834         params => [
835             {type => 'string', desc => 'Authentication token'},
836             {type => 'number', desc => 'Appointment ID'},
837         ],
838         return => { desc => 'Appointment on success, nothing when no appointment found, '.
839                             'an ILS Event on permission error'}
840     }
841 );
842 __PACKAGE__->register_method(
843     method   => "manage_staging_claim",
844     api_name => "open-ils.curbside.unclaim_staging",
845     signature => {
846         params => [
847             {type => 'string', desc => 'Authentication token'},
848             {type => 'number', desc => 'Appointment ID'},
849         ],
850         return => { desc => 'Appointment on success, nothing when no appointment found, '.
851                             'an ILS Event on permission error'}
852     }
853 );
854
855 sub mark_staged {
856     my ($self, $conn, $authtoken, $appointment) = @_;
857     my $e = new_editor(xact => 1, authtoken => $authtoken);
858     return $e->die_event unless $e->checkauth;
859     return $e->die_event unless $e->allowed("STAFF_LOGIN");
860
861     my $slot = $e->retrieve_action_curbside($appointment);
862     return undef unless ($slot);
863
864     $slot->staged('now');
865     $slot->stage_staff($e->requestor->id);
866     $e->update_action_curbside($slot) or return $e->die_event;
867     $e->commit;
868
869     return $e->retrieve_action_curbside($slot->id);
870 }
871 __PACKAGE__->register_method(
872     method   => "mark_staged",
873     api_name => "open-ils.curbside.mark_staged",
874     signature => {
875         params => [
876             {type => 'string', desc => 'Authentication token'},
877             {type => 'number', desc => 'Appointment ID'},
878         ],
879         return => { desc => 'Appointment on success, nothing when no appointment found, '.
880                             'an ILS Event on permission error'}
881     }
882 );
883
884 sub mark_unstaged {
885     my ($self, $conn, $authtoken, $appointment) = @_;
886     my $e = new_editor(xact => 1, authtoken => $authtoken);
887     return $e->die_event unless $e->checkauth;
888     return $e->die_event unless $e->allowed("STAFF_LOGIN");
889
890     my $slot = $e->retrieve_action_curbside($appointment);
891     return undef unless ($slot);
892
893     $slot->clear_staged();
894     $slot->clear_stage_staff();
895     $e->update_action_curbside($slot) or return $e->die_event;
896     $e->commit;
897
898     return $e->retrieve_action_curbside($slot->id);
899 }
900 __PACKAGE__->register_method(
901     method   => "mark_unstaged",
902     api_name => "open-ils.curbside.mark_unstaged",
903     signature => {
904         params => [
905             {type => 'string', desc => 'Authentication token'},
906             {type => 'number', desc => 'Appointment ID'},
907         ],
908         return => { desc => 'Appointment on success, nothing when no appointment found, '.
909                             'an ILS Event on permission error'}
910     }
911 );
912
913 sub mark_arrived {
914     my ($self, $conn, $authtoken, $appointment) = @_;
915     my $e = new_editor(xact => 1, authtoken => $authtoken);
916     return $e->die_event unless $e->checkauth;
917
918     my $slot = $e->retrieve_action_curbside($appointment);
919     return undef unless ($slot);
920
921     unless ($slot->patron == $e->requestor->id) {
922         return $e->die_event unless $e->allowed("STAFF_LOGIN");
923     }
924
925     $slot->arrival('now');
926
927     $e->update_action_curbside($slot) or return $e->die_event;
928     $e->commit;
929
930     return $e->retrieve_action_curbside($slot->id);
931 }
932 __PACKAGE__->register_method(
933     method   => "mark_arrived",
934     api_name => "open-ils.curbside.mark_arrived",
935     signature => {
936         params => [
937             {type => 'string', desc => 'Authentication token'},
938             {type => 'number', desc => 'Appointment ID'},
939         ],
940         return => { desc => 'Appointment on success, nothing when no appointment found, '.
941                             'or an ILS Event on permission error'}
942     }
943 );
944
945 sub mark_delivered {
946     my ($self, $conn, $authtoken, $appointment) = @_;
947     my $e = new_editor(xact => 1, authtoken => $authtoken);
948     return $e->die_event unless $e->checkauth;
949     return $e->die_event unless $e->allowed("STAFF_LOGIN");
950
951     my $slot = $e->retrieve_action_curbside($appointment);
952     return undef unless ($slot);
953
954     if (!$slot->staged) {
955         $slot->staged('now');
956         $slot->stage_staff($e->requestor->id);
957     }
958
959     if (!$slot->arrival) {
960         $slot->arrival('now');
961     }
962
963     $slot->delivered('now');
964     $slot->delivery_staff($e->requestor->id);
965
966     $e->update_action_curbside($slot) or return $e->die_event;
967     $e->commit;
968
969     my $holds = $e->search_action_hold_request({
970         usr => $slot->patron,
971         current_shelf_lib => $slot->org,
972         pickup_lib => $slot->org,
973         shelf_time => {'!=' => undef},
974         cancel_time => undef,
975         fulfillment_time => undef
976     });
977
978     my $circ_sess = OpenSRF::AppSession->connect('open-ils.circ');
979     my @requests = map {
980         $circ_sess->request( # Just try as hard as possible to check out everything
981             'open-ils.circ.checkout.full.override',
982             $authtoken, { patron => $slot->patron, copyid => $_->current_copy }
983         )
984     } @$holds;
985
986     my @successful_checkouts;
987     my $successful_patron;
988     for my $r (@requests) {
989         my $co_res = $r->gather(1);
990         $conn->respond($co_res);
991         next if (ref($co_res) eq 'ARRAY'); # success is always singular
992
993         if ($co_res->{textcode} eq 'SUCCESS') { # that's great news...
994             push @successful_checkouts, $co_res->{payload}->{circ}->id;
995             $successful_patron = $co_res->{payload}->{circ}->usr;
996         }
997     }
998
999     $conn->respond_complete($e->retrieve_action_curbside($slot->id));
1000
1001     $circ_sess->request(
1002         'open-ils.circ.checkout.batch_notify.session.atomic',
1003         $authtoken,
1004         $successful_patron,
1005         \@successful_checkouts
1006     ) if (@successful_checkouts);
1007
1008     $circ_sess->disconnect;
1009     return undef;
1010 }
1011 __PACKAGE__->register_method(
1012     method   => "mark_delivered",
1013     api_name => "open-ils.curbside.mark_delivered",
1014     stream   => 1,
1015     signature => {
1016         params => [
1017             {type => 'string', desc => 'Authentication token'},
1018             {type => 'number', desc => 'Appointment ID'},
1019         ],
1020         return => { desc => 'Nothing for no appointment found, '.
1021                             'a stream of open-ils.circ.checkout.full.override '.
1022                             'responses followed by the finalized slot, '.
1023                             'or an ILS Event on permission error'}
1024     }
1025 );
1026
1027 1;