1 package OpenILS::Application::Booking;
6 use POSIX qw/strftime/;
7 use OpenILS::Application;
8 use base qw/OpenILS::Application/;
10 use OpenILS::Utils::CStoreEditor qw/:funcs/;
11 use OpenILS::Utils::Fieldmapper;
12 use OpenILS::Application::AppUtils;
13 my $U = "OpenILS::Application::AppUtils";
15 use OpenSRF::Utils::Logger qw/$logger/;
18 my ($record_id, $owning_lib, $mvr) = @_;
20 my $brt = new Fieldmapper::booking::resource_type;
22 $brt->name($mvr->title);
23 $brt->record($record_id);
24 $brt->catalog_item('t');
25 $brt->owner($owning_lib);
30 sub get_existing_brt {
31 my ($e, $record_id, $owning_lib, $mvr) = @_;
32 my $results = $e->search_booking_resource_type(
33 {name => $mvr->title, owner => $owning_lib, record => $record_id}
36 return $results->[0] if scalar(@$results) > 0;
43 'open-ils.search.biblio.record.mods_slim.retrieve.authoritative',
48 sub get_unique_owning_libs {
50 $hash{$_->call_number->owning_lib} = 1 foreach (@_); # @_ are copies
54 sub fetch_copies_by_ids {
55 my ($e, $copy_ids) = @_;
56 my $results = $e->search_asset_copy([
58 {flesh => 1, flesh_fields => {acp => ['call_number']}}
60 return $results if ref($results) eq 'ARRAY';
64 sub get_single_record_id {
65 my $record_id = undef;
66 foreach (@_) { # @_ are copies
68 (defined $record_id && $record_id != $_->call_number->record);
69 $record_id = $_->call_number->record;
74 # This function generates the correct json_query clause for determining
75 # whether two given ranges overlap. Each range is composed of a start
76 # and an end point. All four points should be the same type (could be int,
77 # date, time, timestamp, or perhaps other types).
79 # The first range (or the first two points) should be specified as
80 # literal values. The second range (or the last two points) should be
81 # specified as the names of columns, the values of which in a given row
82 # will constitute the second range in the comparison.
84 # ALSO: PostgreSQL includes an OVERLAPS operator which provides the same
85 # functionality in a much more concise way, but json_query does not (yet).
86 sub json_query_ranges_overlap {
88 { '-and' => [{$_[2] => {'>=', $_[0]}}, {$_[2] => {'<', $_[1]}}]},
89 { '-and' => [{$_[3] => {'>', $_[0]}}, {$_[3] => {'<', $_[1]}}]},
90 { '-and' => { $_[3] => {'>', $_[0]}, $_[2] => {'<=', $_[0]}}},
91 { '-and' => { $_[3] => {'>', $_[1]}, $_[2] => {'<', $_[1]}}},
95 sub create_brt_and_brsrc {
96 my ($self, $conn, $authtoken, $copy_ids) = @_;
97 my (@created_brt, @created_brsrc);
100 my $e = new_editor(xact => 1, authtoken => $authtoken);
101 return $e->die_event unless $e->checkauth;
103 my @copies = @{fetch_copies_by_ids($e, $copy_ids)};
104 my $record_id = get_single_record_id(@copies) or return $e->die_event;
105 my $mvr = get_mvr($record_id) or return $e->die_event;
107 foreach (get_unique_owning_libs(@copies)) {
108 $brt_table{$_} = get_existing_brt($e, $record_id, $_, $mvr) ||
109 prepare_new_brt($record_id, $_, $mvr);
112 while (my ($owning_lib, $brt) = each %brt_table) {
113 my $pre_existing = 1;
115 if ($e->allowed('ADMIN_BOOKING_RESOURCE_TYPE', $owning_lib)) {
117 return $e->die_event unless (
118 # v-- Important: assignment modifies original hash
119 $brt = $e->create_booking_resource_type($brt)
123 push @created_brt, [$brt->id, $brt->record, $pre_existing];
128 'ADMIN_BOOKING_RESOURCE', $_->call_number->owning_lib
130 # This block needs to disregard any cstore failures and just
131 # return what results it can.
132 my $brsrc = new Fieldmapper::booking::resource;
134 $brsrc->type($brt_table{$_->call_number->owning_lib}->id);
135 $brsrc->owner($_->call_number->owning_lib);
136 $brsrc->barcode($_->barcode);
138 $e->set_savepoint("alpha");
139 my $pre_existing = 0;
140 my $usable_result = undef;
141 if (!($usable_result = $e->create_booking_resource($brsrc))) {
142 $e->rollback_savepoint("alpha");
143 if (($usable_result = $e->search_booking_resource(
144 +{ map { ($_, $brsrc->$_()) } qw/type owner barcode/ }
146 $usable_result = $usable_result->[0];
149 # So we failed to create a booking resource for this copy.
150 # For now, let's just keep going. If the calling app wants
151 # to consider this an error, it can notice the absence
152 # of a booking resource for the copy in the returned
155 "Couldn't create or find brsrc for acp #" . $_->id
159 $e->release_savepoint("alpha");
162 if ($usable_result) {
164 [$usable_result->id, $_->id, $pre_existing];
170 return {brt => \@created_brt, brsrc => \@created_brsrc} or
171 return $e->die_event;
173 __PACKAGE__->register_method(
174 method => "create_brt_and_brsrc",
175 api_name => "open-ils.booking.resources.create_from_copies",
178 {type => 'string', desc => 'Authentication token'},
179 {type => 'array', desc => 'Copy IDs'},
181 return => { desc => "A two-element hash. The 'brt' element " .
182 "is a list of created booking resource types described by " .
183 "3-tuples (id, copy id, was pre-existing). The 'brsrc' " .
184 "element is a similar list of created booking resources " .
185 "described by (id, record id, was pre-existing) 3-tuples."}
191 my ($self, $client, $authtoken,
192 $target_user_barcode, $datetime_range,
193 $brt, $brsrc_list, $attr_values) = @_;
195 $brsrc_list = [ undef ] if not defined $brsrc_list;
196 return undef if scalar(@$brsrc_list) < 1; # Empty list not ok.
198 my $e = new_editor(xact => 1, authtoken => $authtoken);
199 return $e->die_event unless (
201 $e->allowed("ADMIN_BOOKING_RESERVATION") and
202 $e->allowed("ADMIN_BOOKING_RESERVATION_ATTR_MAP")
205 my $usr = $U->fetch_user_by_barcode($target_user_barcode);
206 return $usr if ref($usr) eq 'HASH' and exists($usr->{"ilsevent"});
209 foreach my $brsrc (@$brsrc_list) {
210 my $bresv = new Fieldmapper::booking::reservation;
211 $bresv->usr($usr->id);
212 $bresv->request_lib($e->requestor->ws_ou);
213 $bresv->pickup_lib($e->requestor->ws_ou);
214 $bresv->start_time($datetime_range->[0]);
215 $bresv->end_time($datetime_range->[1]);
217 # A little sanity checking: don't agree to put a reservation on a
218 # brsrc and a brt when they don't match. In fact, bomb out of
219 # this transaction entirely.
221 my $brsrc_itself = $e->retrieve_booking_resource($brsrc) or
222 return $e->die_event;
223 return $e->die_event if ($brsrc_itself->type != $brt);
225 $bresv->target_resource($brsrc); # undef is ok here
226 $bresv->target_resource_type($brt);
228 ($bresv = $e->create_booking_reservation($bresv)) or
229 return $e->die_event;
231 # We could/should do some sanity checking on this too: namely, on
232 # whether the attribute values given actually apply to the relevant
233 # brt. Not seeing any grievous side effects of not checking, though.
235 foreach my $value (@$attr_values) {
236 my $bravm = new Fieldmapper::booking::reservation_attr_value_map;
237 $bravm->reservation($bresv->id);
238 $bravm->attr_value($value);
239 $bravm = $e->create_booking_reservation_attr_value_map($bravm) or
240 return $e->die_event;
244 "bresv" => $bresv->id,
249 $e->commit or return $e->die_event;
251 # Targeting must be tacked on _after_ committing the transaction where the
252 # reservations are actually created.
253 foreach (@$results) {
254 $_->{"targeting"} = $U->storagereq(
255 "open-ils.storage.booking.reservation.resource_targeter",
261 __PACKAGE__->register_method(
262 method => "create_bresv",
263 api_name => "open-ils.booking.reservations.create",
266 {type => 'string', desc => 'Authentication token'},
267 {type => 'string', desc => 'Barcode of user for whom to reserve'},
268 {type => 'array', desc => 'Two elements: start and end timestamp'},
269 {type => 'int', desc => 'Booking resource type'},
270 {type => 'list', desc => 'Booking resource (undef ok; empty not ok)'},
271 {type => 'array', desc => 'Attribute values selected'},
273 return => { desc => "A hash containing the new bresv and a list " .
279 sub resource_list_by_attrs {
282 my $auth = shift; # Keep as argument, though not used just now.
285 return undef unless ($filters->{type} || $filters->{attribute_values});
288 'select' => { brsrc => [ 'id' ] },
289 'from' => { brsrc => {} },
294 $query->{where} = {"-and" => []};
295 if ($filters->{type}) {
296 push @{$query->{where}->{"-and"}}, {"type" => $filters->{type}};
299 if ($filters->{attribute_values}) {
301 $query->{from}->{brsrc}->{bram} = { field => 'resource' };
303 $filters->{attribute_values} = [$filters->{attribute_values}]
304 if (!ref($filters->{attribute_values}));
306 $query->{having}->{'+bram'}->{value}->{'@>'} = {
307 transform => 'array_accum',
308 value => '$_' . $$ . '${' .
309 join(',', @{$filters->{attribute_values}}) .
314 if ($filters->{available}) {
315 # If only one timestamp has been provided, make it into a range.
316 if (!ref($filters->{available})) {
317 $filters->{available} = [($filters->{available}) x 2];
320 push @{$query->{where}->{"-and"}}, {
324 "select" => {"bresv" => ["id"]},
326 "where" => {"-and" => [
327 json_query_ranges_overlap(
328 $filters->{available}->[0],
329 $filters->{available}->[1],
333 {"cancel_time" => undef},
334 {"current_resource" => {"=" => {"+brsrc" => "id"}}}
340 if ($filters->{booked}) {
341 # If only one timestamp has been provided, make it into a range.
342 if (!ref($filters->{booked})) {
343 $filters->{booked} = [($filters->{booked}) x 2];
346 push @{$query->{where}->{"-and"}}, {
348 "select" => {"bresv" => ["id"]},
350 "where" => {"-and" => [
351 json_query_ranges_overlap(
352 $filters->{booked}->[0],
353 $filters->{booked}->[1],
357 {"cancel_time" => undef},
358 {"current_resource" => { "=" => {"+brsrc" => "id"}}}
362 # I think that the "booked" case could be done with a JOIN instead of
363 # an EXISTS, but I'm leaving it this way for symmetry with the
364 # "available" case for now. The available case cannot be done with a
368 my $cstore = OpenSRF::AppSession->connect('open-ils.cstore');
369 my $rows = $cstore->request( 'open-ils.cstore.json_query.atomic', $query )->gather(1);
372 return @$rows ? [map { $_->{id} } @$rows] : [];
374 __PACKAGE__->register_method(
375 method => "resource_list_by_attrs",
376 api_name => "open-ils.booking.resources.filtered_id_list",
380 {type => 'string', desc => 'Authentication token (unused for now,' .
381 ' but at least pass undef here)'},
382 {type => 'object', desc => 'Filter object: see notes for details'},
383 {type => 'bool', desc => 'Return whole objects instead of IDs?'}
385 return => { desc => "An array of brsrc ids matching the requested filters." },
389 The filter object parameter can contain the following keys:
390 * type => The id of a booking resource type (brt)
391 * attribute_values => The ids of booking resource type attribute values that the resource must have assigned to it (brav)
392 * available => Either:
393 A timestamp during which the resources are not reserved. If the resource is overbookable, this is ignored.
394 A range of two timestamps which do not overlap any reservations for the resources. If the resource is overbookable, this is ignored.
396 A timestamp during which the resources are reserved.
397 A range of two timestamps which overlap a reservation of the resources.
399 Note that at least one of 'type' or 'attribute_values' is required.
405 sub reservation_list_by_filters {
410 my $whole_obj = shift;
412 return undef unless ($filters->{user} || $filters->{user_barcode} || $filters->{resource} || $filters->{type} || $filters->{attribute_values});
414 my $e = new_editor(authtoken=>$auth);
415 return $e->event unless $e->checkauth;
416 return $e->event unless $e->allowed('VIEW_TRANSACTION');
419 'select' => { bresv => [ 'id', 'start_time' ] },
420 'from' => { bresv => {} },
422 'order_by' => [{ class => bresv => field => start_time => direction => 'asc' }],
426 if ($filters->{fields}) {
427 $query->{where} = $filters->{fields};
431 if ($filters->{user}) {
432 $query->{where}->{usr} = $filters->{user};
434 elsif ($filters->{user_barcode}) { # just one of user and user_barcode
435 my $usr = $U->fetch_user_by_barcode($filters->{user_barcode});
436 return $usr if ref($usr) eq 'HASH' and exists($usr->{"ilsevent"});
437 $query->{where}->{usr} = $usr->id;
441 if ($filters->{type}) {
442 $query->{where}->{target_resource_type} = $filters->{type};
445 if ($filters->{resource}) {
446 $query->{where}->{target_resource} = $filters->{resource};
449 if ($filters->{attribute_values}) {
451 $query->{from}->{bresv}->{bravm} = { field => 'reservation' };
453 $filters->{attribute_values} = [$filters->{attribute_values}]
454 if (!ref($filters->{attribute_values}));
456 $query->{having}->{'+bravm'}->{attr_value}->{'@>'} = {
457 transform => 'array_accum',
458 value => '$_' . $$ . '${' .
459 join(',', @{$filters->{attribute_values}}) .
464 if ($filters->{search_start} || $filters->{search_end}) {
465 $query->{where}->{'-or'} = {};
467 $query->{where}->{'-or'}->{start_time} = { 'between' => [ $filters->{search_start}, $filters->{search_end} ] }
468 if ($filters->{search_start});
470 $query->{where}->{'-or'}->{end_time} = { 'between' => [ $filters->{search_start}, $filters->{search_end} ] }
471 if ($filters->{search_end});
474 my $cstore = OpenSRF::AppSession->connect('open-ils.cstore');
475 my $ids = [ map { $_->{id} } @{
477 'open-ils.cstore.json_query.atomic', $query
482 if (not $whole_obj) {
487 my $bresv_list = $e->search_booking_reservation([
492 [qw/target_resource current_resource target_resource_type/]
497 return $bresv_list ? $bresv_list : [];
499 __PACKAGE__->register_method(
500 method => "reservation_list_by_filters",
501 api_name => "open-ils.booking.reservations.filtered_id_list",
505 {type => 'string', desc => 'Authentication token'},
506 {type => 'object', desc => 'Filter object -- see notes for details'}
508 return => { desc => "An array of bresv ids matching the requested filters." },
512 The filter object parameter can contain the following keys:
513 * user => The id of a user that has requested a bookable item -- filters on bresv.usr
514 * barcode => The barcode of a user that has requested a bookable item
515 * type => The id of a booking resource type (brt) -- filters on bresv.target_resource_type
516 * resource => The id of a booking resource (brsrc) -- filters on bresv.target_resource
517 * attribute_values => The ids of booking resource type attribute values that the resource must have assigned to it (brav)
518 * search_start => If search_end is not specified, booking interval (start_time to end_time) must contain this timestamp.
519 * search_end => If search_start is not specified, booking interval (start_time to end_time) must contain this timestamp.
520 * fields => An object containing any combination of bresv search filters in standard cstore/pcrud search format.
522 Note that at least one of 'user', 'type', 'resource' or 'attribute_values' is required. If both search_start and search_end are specified,
523 then the result includes any reservations that overlap with that time range. Any filter fields supplied in 'fields' are overridden
524 by the top-level filters ('user', 'type', 'resource').
530 sub naive_ts_string { strftime("%F %T", localtime(shift)); }
533 my ($self, $client, $auth, $range, $interval_secs, $pickup_lib) = @_;
535 my $e = new_editor(xact => 1, authtoken => $auth);
536 return $e->die_event unless $e->checkauth;
537 return $e->die_event unless $e->allowed('RETRIEVE_RESERVATION_PULL_LIST');
538 return $e->die_event unless (
539 ref($range) eq 'ARRAY' or
540 ($interval_secs = int($interval_secs)) > 0
543 $range = [ naive_ts_string(time), naive_ts_string(time + $interval_secs) ]
546 my @fundamental_constraints = (
547 {"current_resource" => {"!=" => undef}},
548 {"capture_time" => undef},
549 {"cancel_time" => undef},
550 {"return_time" => undef},
551 {"pickup_time" => undef}
559 "column" => "start_time",
560 "transform" => "min",
568 json_query_ranges_overlap(
569 $range->[0], $range->[1], "start_time", "end_time"
571 @fundamental_constraints
576 push @{$query->{"where"}->{"-and"}}, {"pickup_lib" => $pickup_lib};
579 my $rows = $e->json_query($query);
580 my %resource_id_map = ();
584 "select" => {"bresv" => ["id"]},
588 {"current_resource" => "PLACEHOLDER"},
589 {"start_time" => "PLACEHOLDER"},
594 push @{$id_query->{"where"}->{"-and"}},
595 {"pickup_lib" => $pickup_lib};
599 $id_query->{"where"}->{"-and"}->[0]->{"current_resource"} =
600 $_->{"current_resource"};
601 $id_query->{"where"}->{"-and"}->[1]->{"start_time"} =
604 my $results = $e->json_query($id_query);
606 my @these_ids = map { $_->{"id"} } @$results;
607 push @all_ids, @these_ids;
609 $resource_id_map{$_->{"current_resource"}} = [@these_ids];
615 map { $_->id => $_ } @{
616 $e->search_booking_reservation([{"id" => [@all_ids]}, {
618 flesh_fields => { bresv => [
620 "target_resource_type",
629 my $one = $bresv_lookup{$resource_id_map{$key}->[0]};
631 "current_resource" => $one->current_resource,
632 "target_resource_type" => $one->target_resource_type,
634 map { $bresv_lookup{$_} } @{$resource_id_map{$key}}
637 foreach (@{$result->{"reservations"}}) { # deflesh
638 $_->current_resource($_->current_resource->id);
639 $_->target_resource_type($_->target_resource_type->id);
642 } keys %resource_id_map ];
648 __PACKAGE__->register_method(
649 method => "get_pull_list",
650 api_name => "open-ils.booking.reservations.get_pull_list",
654 {type => "string", desc => "Authentication token"},
655 {type => "array", desc =>
656 "range: Date/time range for reservations (opt)"},
657 {type => "int", desc =>
658 "interval: Seconds from now (instead of range)"},
659 {type => "number", desc => "(Optional) Pickup library"}
661 return => { desc => "An array of hashes, each containing key/value " .
662 "pairs describing resource, resource type, and a list of " .
663 "reservations that claim the given resource." }
668 sub get_copy_fleshed_just_right {
669 my ($self, $client, $auth, $barcode) = @_;
671 my $e = new_editor(authtoken => $auth);
672 my $results = $e->search_asset_copy([
673 {"barcode" => $barcode},
676 "flesh_fields" => {"acp" => [qw/call_number location/]}
680 if (ref($results) eq 'ARRAY') {
682 return $results->[0] unless ref $barcode;
683 return +{ map { $_->barcode => $_ } @$results };
685 return $e->die_event;
688 __PACKAGE__->register_method(
689 method => "get_copy_fleshed_just_right",
690 api_name => "open-ils.booking.asset.get_copy_fleshed_just_right",
694 {type => "string", desc => "Authentication token"},
695 {type => "mixed", desc => "One barcode or an array of them"},
698 "A copy, or a hash of copies keyed by barcode if an array of " .
705 sub capture_reservation {
711 my $e = new_editor(xact => 1, authtoken => $auth);
712 return $e->event unless $e->checkauth;
713 return $e->event unless $e->allowed('CAPTURE_RESERVATION');
714 my $here = $e->requestor->ws_ou;
716 my $reservation = $e->retrieve_booking_reservation( $res_id );
717 return OpenILS::Event->new('RESERVATION_NOT_FOUND') unless $reservation;
719 return OpenILS::Event->new('RESERVATION_CAPTURE_FAILED', payload => { captured => 0, fail_cause => 'no-resource' })
720 if (!$reservation->current_resource); # no resource
722 return OpenILS::Event->new('RESERVATION_CAPTURE_FAILED', payload => { captured => 0, fail_cause => 'cancelled' })
723 if ($reservation->cancel_time); # canceled
725 my $resource = $e->retrieve_booking_resource( $reservation->current_resource );
726 my $type = $e->retrieve_booking_resource( $resource->type );
728 $reservation->capture_staff( $e->requestor->id );
729 $reservation->capture_time( 'now' );
731 return $e->event unless ( $e->update_booking_reservation( $reservation ) and $reservation = $e->data );
733 my $ret = { captured => 1, reservation => $reservation };
735 if ($here != $reservation->pickup_lib) {
736 return OpenILS::Event->new('RESERVATION_CAPTURE_FAILED', payload => { captured => 0, fail_cause => 'not-transferable' })
737 if (!$U->is_true($type->transferable)); # non-transferable resource
739 # need to transit the item ... is it already in transit?
740 my $transit = $e->search_action_reservation_transit_copy( { reservation => $res_id, dest_recv_time => undef } )->[0];
742 if (!$transit) { # not yet in transit
743 $transit = new Fieldmapper::action::reservation_transit_copy ();
745 $transit->copy($resource->id);
746 $transit->copy_status(15);
747 $transit->source_send_time('now');
748 $transit->source($here);
749 $transit->dest($reservation->pickup_lib);
751 $e->create_action_reservation_transit_copy( $transit );
753 if ($U->is_true($type->catalog_item)) {
754 my $copy = $e->search_asset_copy( { barcode => $resource->barcode, deleted => 'f' } )->[0];
757 return OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $copy) if ($copy->status == 1);
759 $e->update_asset_copy( $copy );
760 $$ret{catalog_item} = $e->data;
765 $$ret{transit} = $transit;
766 } elsif ($U->is_true($type->catalog_item)) {
767 my $copy = $e->search_asset_copy( { barcode => $resource->barcode, deleted => 'f' } )->[0];
770 return OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => { captured => 0, copy => $copy }) if ($copy->status == 1);
772 $e->update_asset_copy( $copy );
773 $$ret{catalog_item} = $e->data;
779 return OpenILS::Event->new('SUCCESS', payload => $ret);
781 __PACKAGE__->register_method(
782 method => "capture_reservation",
783 api_name => "open-ils.booking.reservations.capture",
787 {type => 'string', desc => 'Authentication token'},
788 {type => 'number', desc => 'Reservation ID'}
790 return => { desc => "An OpenILS Event object describing the outcome of the capture, with relevant payload." },