1 package OpenILS::Application::Acq::Search;
2 use base "OpenILS::Application";
7 use OpenSRF::AppSession;
8 use OpenSRF::Utils::Logger qw/:logger/;
10 use OpenILS::Utils::CStoreEditor q/:funcs/;
11 use OpenILS::Utils::Fieldmapper;
12 use OpenILS::Application::Acq::Lineitem;
13 use OpenILS::Application::Acq::Financials;
14 use OpenILS::Application::Acq::Picklist;
15 use OpenILS::Application::Acq::Invoice;
16 use OpenILS::Application::Acq::Order;
20 \&{"OpenILS::Application::Acq::Lineitem::retrieve_lineitem_impl"},
22 \&{"OpenILS::Application::Acq::Picklist::retrieve_picklist_impl"},
23 "purchase_order" => \&{
24 "OpenILS::Application::Acq::Financials::retrieve_purchase_order_impl"
27 "OpenILS::Application::Acq::Invoice::fetch_invoice_impl"
31 sub F { $Fieldmapper::fieldmap->{"Fieldmapper::" . $_[0]}; }
33 # This subroutine returns 1 if the argument is a) a scalar OR
34 # b) an array of ONLY scalars. Otherwise it returns 0.
37 return 1 unless ref $o;
38 if (ref($o) eq "ARRAY") {
39 foreach (@$o) { return 0 if ref $_; }
45 # Returns 1 if and only if argument is an array of exactly two scalars.
48 if (ref $o eq "ARRAY") {
49 return 1 if (scalar(@$o) == 2 && (!ref $o->[0] && !ref $o->[1]));
55 my ($value, $gte, $lte) = @_;
61 # avoid transforming a date if the match value is NULL.
62 return {'=' => undef} if $op eq '=' and not $value;
64 +{$op => {"transform" => "date", "value" => $value}};
67 sub prepare_acqlia_search_and {
71 foreach my $unit (@{$acqlia}) {
73 "select" => {"acqlia" => ["id"]},
75 "where" => {"-and" => [{"lineitem" => {"=" => {"+jub" => "id"}}}]}
78 # castdate not supported for acqlia fields: they're all type text
79 my ($k, $v, $fuzzy, $between, $not) = breakdown_term($unit);
80 my $point = $subquery->{"where"}->{"-and"};
83 push @$point, {"definition" => $k};
85 if ($fuzzy and not ref $v) {
86 push @$point, {"attr_value" => {"ilike" => "%" . $v . "%"}};
87 } elsif ($between and could_be_range($v)) {
88 push @$point, {"attr_value" => {"between" => $v}};
89 } elsif (check_1d_max($v)) {
90 push @$point, {"attr_value" => $v};
95 my $operator = $not ? "-not-exists" : "-exists";
96 push @phrases, {$operator => $subquery};
101 sub prepare_acqlia_search_or {
105 my $result = {"+acqlia" => {"-or" => $point}};
107 foreach my $unit (@$acqlia) {
108 # castdate not supported for acqlia fields: they're all type text
109 my ($k, $v, $fuzzy, $between, $not) = breakdown_term($unit);
111 if ($fuzzy and not ref $v) {
115 "attr_value" => {"ilike" => "%" . $v . "%"}
118 } elsif ($between and could_be_range($v)) {
121 "definition" => $k, "attr_value" => {"between" => $v}
124 } elsif (check_1d_max($v)) {
126 "-and" => {"definition" => $k, "attr_value" => $v}
132 push @$point, $not ? {"-not" => $term_clause} : $term_clause;
140 my $key = (grep { !/^__/ } keys %$term)[0];
143 $term->{"__fuzzy"} ? 1 : 0,
144 $term->{"__between"} ? 1 : 0,
145 $term->{"__not"} ? 1 : 0,
146 $term->{"__castdate"} ? 1 : 0,
147 $term->{"__gte"} ? 1 : 0,
148 $term->{"__lte"} ? 1 : 0
152 sub get_fm_links_by_hint {
154 foreach my $field (values %{$Fieldmapper::fieldmap}) {
155 return $field->{"links"} if $field->{"hint"} eq $hint;
161 my ($value, $n) = @_;
163 "=" => { transform => "lowercase", value => lc($value) }
168 {"+au$n" => {"usrname" => $value}},
169 {"+au$n" => {"first_given_name" => $lc_value}},
170 {"+au$n" => {"second_given_name" => $lc_value}},
171 {"+au$n" => {"family_name" => $lc_value}},
172 {"+ac$n" => {"barcode" => $value}}
177 # go through the terms hash, find keys that correspond to fields links
178 # to actor.usr, and rewrite the search as one that searches not by
179 # actor.usr.id but by any of these user properties: card barcode, username,
180 # given names and family name.
181 sub prepare_au_terms {
182 my ($terms, $join_num) = @_;
188 foreach my $conj (qw/-and -or/) {
189 next unless exists $terms->{$conj};
191 my @new_outer_terms = ();
192 HINT_UNIT: foreach my $hint_unit (@{$terms->{$conj}}) {
193 my $hint = (keys %$hint_unit)[0];
194 (my $plain_hint = $hint) =~ y/+//d;
195 if ($hint eq "-not") {
196 $hint_unit = $hint_unit->{$hint};
201 if (my $links = get_fm_links_by_hint($plain_hint) and
202 $plain_hint ne "acqlia") {
204 my ($attr, $value) = breakdown_term($hint_unit->{$hint});
205 if ($links->{$attr} and
206 $links->{$attr}->{"class"} eq "au") {
207 push @joins, [$plain_hint, $attr, $join_num];
208 my $au_term = gen_au_term($value, $join_num);
210 $au_term = {"-not" => $au_term};
213 push @new_outer_terms, $au_term;
215 delete $hint_unit->{$hint};
219 $hint_unit = {"-not" => $hint_unit};
222 push @new_outer_terms, $hint_unit if scalar keys %$hint_unit;
224 $terms->{$conj} = [ @new_outer_terms ];
230 my ($terms, $is_and) = @_;
232 my $conj = $is_and ? "-and" : "-or";
233 my $outer_clause = {};
235 foreach my $class (qw/acqpo acqpl acqinv jub acqlid acqlisum acqlisumi/) {
236 next if not exists $terms->{$class};
238 $outer_clause->{$conj} = [] unless $outer_clause->{$conj};
239 foreach my $unit (@{$terms->{$class}}) {
241 my ($k, $v, $fuzzy, $between, $not, $castdate, $gte, $lte) =
242 breakdown_term($unit);
245 if ($fuzzy and not ref $v) {
246 $term_clause = {$k => {"ilike" => "%" . $v . "%"}};
247 } elsif ($between and could_be_range($v)) {
248 $term_clause = {$k => {"between" => $v}};
249 } elsif (check_1d_max($v)) {
251 $v = castdate($v, $gte, $lte) if $castdate;
252 } elsif ($gte or $lte) {
253 my $op = $gte ? '>=' : '<=';
255 } elsif (not ref $v and $not) {
256 # the old way, NOT (blah.id = $v) needs to be
257 # (blah.id <> $x OR blah.id IS NULL)
258 $not = 0; # avoid the regular negative transformation
261 {"+$class" => {$k => {"!=" => $v}}},
262 {"+$class" => {$k => undef}}
266 $term_clause = {$k => $v};
271 if ($special_clause) {
272 push @{$outer_clause->{$conj}}, $special_clause;
274 my $clause = {"+" . $class => $term_clause};
275 $clause = {"-not" => $clause} if $not;
276 push @{$outer_clause->{$conj}}, $clause;
281 if ($terms->{"acqlia"}) {
282 push @{$outer_clause->{$conj}},
283 $is_and ? prepare_acqlia_search_and($terms->{"acqlia"}) :
284 prepare_acqlia_search_or($terms->{"acqlia"});
287 return undef unless scalar keys %$outer_clause;
292 my $graft_map = shift;
293 my $core_hint = shift;
296 foreach my $join (@_) {
297 my ($hint, $attr, $num) = @$join;
298 my $start = $graft_map->{$hint};
314 if ($hint eq $core_hint) {
315 $start->{"au$num"} = $clause;
317 $start->{"join"} ||= {};
318 $start->{"join"}->{"au$num"} = $clause;
326 sub build_from_clause_and_joins {
327 my ($query, $core, $and_terms, $or_terms) = @_;
331 $graft_map{$core} = $query->{from}{$core} = {};
333 my $join_type = keys(%$or_terms) ? "left" : "inner";
335 my @classes = grep { $core ne $_ } (keys(%$and_terms), keys(%$or_terms));
336 my %classes_uniq = map { $_ => 1 } @classes;
337 @classes = keys(%classes_uniq);
339 my $acqlia_join = sub {
340 return {"type" => "left", "field" => "lineitem", "fkey" => "id"};
343 foreach my $class (@classes) {
344 if ($class eq 'acqlia') {
345 if ($core eq 'acqinv') {
347 $query->{from}{$core}{acqmapinv}{join}{jub}{join}{acqlia} =
349 } elsif ($core eq 'jub') {
351 $query->{from}{$core}{acqlia} =
355 $query->{from}{$core}{jub}{join}{acqlia} =
358 } elsif ($class eq 'acqinv' or $core eq 'acqinv') {
360 $query->{from}{$core}{acqmapinv}{join}{$class} ||= {};
361 $graft_map{$class}{type} = "left"; # $join_type
363 $graft_map{$class} = $query->{from}{$core}{$class} ||= {};
364 $graft_map{$class}{type} = $join_type;
366 # without this, the SQL attempts to join on
367 # jub.order_summary, which is a virtual field.
368 $graft_map{$class}{field} = 'lineitem'
369 if $class eq 'acqlisum' or $class eq 'acqlisumi';
376 __PACKAGE__->register_method(
377 method => "unified_search",
378 api_name => "open-ils.acq.lineitem.unified_search",
381 desc => q/Returns lineitems based on flexible search terms./,
383 {desc => "Authentication token", type => "string"},
384 {desc => "Field/value pairs for AND'ing", type => "object"},
385 {desc => "Field/value pairs for OR'ing", type => "object"},
386 {desc => "Conjunction between AND pairs and OR pairs " .
387 "(can be 'and' or 'or')", type => "string"},
388 {desc => "Retrieval options (clear_marc, flesh_notes, etc) " .
389 "- XXX detail all the options",
392 return => {desc => "A stream of LIs on success, Event on failure"}
396 __PACKAGE__->register_method(
397 method => "unified_search",
398 api_name => "open-ils.acq.purchase_order.unified_search",
401 desc => q/Returns purchase orders based on flexible search terms.
402 See open-ils.acq.lineitem.unified_search/,
403 return => {desc => "A stream of POs on success, Event on failure"}
407 __PACKAGE__->register_method(
408 method => "unified_search",
409 api_name => "open-ils.acq.picklist.unified_search",
412 desc => q/Returns pick lists based on flexible search terms.
413 See open-ils.acq.lineitem.unified_search/,
414 return => {desc => "A stream of PLs on success, Event on failure"}
418 __PACKAGE__->register_method(
419 method => "unified_search",
420 api_name => "open-ils.acq.invoice.unified_search",
423 desc => q/Returns invoices lists based on flexible search terms.
424 See open-ils.acq.lineitem.unified_search/,
425 return => {desc => "A stream of invoices on success, Event on failure"}
430 my ($self, $conn, $auth, $and_terms, $or_terms, $conj, $options) = @_;
433 my $e = new_editor("authtoken" => $auth);
434 return $e->die_event unless $e->checkauth;
436 # What kind of object are we returning? Important: (\w+) had better be
437 # a legit acq classname particle, so don't register any crazy api_names.
438 my $ret_type = ($self->api_name =~ /cq.(\w+).un/)[0];
439 my $retriever = $RETRIEVERS{$ret_type};
440 my $hint = F("acq::$ret_type")->{"hint"};
442 my $select_clause = {
443 $hint => [{"column" => "id", "transform" => "distinct"}]
446 my $attr_from_filter;
447 if ($options->{"order_by"}) {
448 # What's the point of this block? When using ORDER BY in conjuction
449 # with SELECT DISTINCT, the fields present in ORDER BY have to also
450 # be in the SELECT clause. This will take _one_ such field and add
451 # it to the SELECT clause as needed.
452 my ($order_by, $class, $field);
454 ($order_by = $options->{"order_by"}->[0]) &&
455 ($class = $order_by->{"class"}) =~ /^[\da-z_]+$/ &&
456 ($field = $order_by->{"field"}) =~ /^[\da-z_]+$/
459 return new OpenILS::Event(
460 "BAD_PARAMS", "note" =>
461 q/order_by clause must be of the long form, like:
462 "order_by": [{"class": "foo", "field": "bar", "direction": "asc"}]/
467 # we can't combine distinct(id) with another select column,
468 # since the non-distinct column may arbitrarily (via hash keys)
469 # sort to the front of the final SQL, which PG will complain about.
470 $select_clause = { $hint => ["id"] };
471 $select_clause->{$class} ||= [];
472 push @{$select_clause->{$class}},
473 {column => $field, transform => 'first', aggregate => 1};
475 # when sorting by LI attr values, we have to limit
476 # to a specific type of attr value to sort on.
477 if ($class eq 'acqlia') {
478 $attr_from_filter = {
481 "attr_type" => "lineitem_marc_attr_definition",
482 "attr_name" => $options->{"order_by_attr"} || "title"
492 select => $select_clause,
493 order_by => ($options->{order_by} || {$hint => {id => {}}}),
494 offset => ($options->{offset} || 0)
497 $query->{"limit"} = $options->{"limit"} if $options->{"limit"};
499 my $graft_map = build_from_clause_and_joins(
500 $query, $hint, $and_terms, $or_terms
503 $and_terms = prepare_terms($and_terms, 1);
504 $or_terms = prepare_terms($or_terms, 0);
506 my $offset = add_au_joins($graft_map, $hint, prepare_au_terms($and_terms));
507 add_au_joins($graft_map, $hint, prepare_au_terms($or_terms, $offset));
509 # The join to acqmapinv needs to be a left join when present.
510 if ($query->{from}{$hint}{acqmapinv}) {
511 $query->{from}{$hint}{acqmapinv}{type} = "left";
514 if ($and_terms and $or_terms) {
515 $query->{"where"} = {
516 "-" . (lc $conj eq "or" ? "or" : "and") => [$and_terms, $or_terms]
518 } elsif ($and_terms) {
519 $query->{"where"} = $and_terms;
520 } elsif ($or_terms) {
521 $query->{"where"} = $or_terms;
524 return new OpenILS::Event("BAD_PARAMS", "desc" => "No usable terms");
528 # if ordering by acqlia, insert the from clause
529 # filter to limit to one type of attr.
530 if ($attr_from_filter) {
531 $query->{from}->{jub} = {} unless $query->{from}->{jub};
532 $query->{from}->{jub}->{acqlia} = $attr_from_filter;
535 my $results = $e->json_query($query) or return $e->die_event;
536 my @id_list = map { $_->{"id"} } (grep { $_->{"id"} } @$results);
539 my $resp = $retriever->($e, $_, $options);
540 next if(ref($resp) ne "Fieldmapper::acq::$ret_type");
541 $conn->respond($options->{"id_list"} ? $_ : $resp);
548 __PACKAGE__->register_method(
549 method => "bib_search",
550 api_name => "open-ils.acq.biblio.wrapped_search",
553 desc => q/Returns new lineitems for each matching bib record/,
555 {desc => "Authentication token", type => "string"},
556 {desc => "search string", type => "string"},
557 {desc => "search options", type => "object"}
559 return => {desc => "A stream of LIs on success, Event on failure"}
563 __PACKAGE__->register_method(
564 method => "bib_search",
565 api_name => "open-ils.acq.biblio.create_by_id",
568 desc => q/Returns new lineitems for each matching bib record/,
570 {desc => "Authentication token", type => "string"},
571 {desc => "list of bib IDs", type => "array"},
572 {desc => "options (for lineitem fleshing)", type => "object"}
574 return => {desc => "A stream of LIs on success, Event on failure"}
578 # This is very similar to zsearch() in Order.pm
580 my ($self, $conn, $auth, $search, $opts) = @_;
582 my $e = new_editor("authtoken" => $auth, "xact" => 1);
583 return $e->die_event unless $e->checkauth;
584 return $e->die_event unless $e->allowed("CREATE_PICKLIST");
586 my $mgr = new OpenILS::Application::Acq::BatchManager(
587 "editor" => $e, "conn" => $conn
594 if ($self->api_name =~ /create_by_id/) {
595 $search = [ sort @$search ]; # for consitency
596 my $bibs = $e->search_biblio_record_entry(
597 {"id" => $search}, {"order_by" => {"bre" => ["id"]}}
598 ) or return $e->die_event;
600 if ($opts->{"reuse_picklist"}) {
601 $picklist = $e->retrieve_acq_picklist($opts->{"reuse_picklist"}) or
602 return $e->die_event;
603 return $e->die_event unless
604 $e->allowed("UPDATE_PICKLIST", $picklist->org_unit);
606 # If we're reusing an existing picklist, we don't need to
607 # create new lineitems for any bib records for which we already
609 my $already_have = $e->search_acq_lineitem({
610 "picklist" => $picklist->id,
611 "eg_bib_id" => [ map { $_->id } @$bibs ]
612 }) or return $e->die_event;
614 # So in that case we a) save the lineitem id's of the relevant
615 # items that already exist so that we can return those items later,
616 # and b) remove the bib id's in question from our list of bib
617 # id's to lineitemize.
618 if (@$already_have) {
619 push @li_ids, $_->id foreach (@$already_have);
621 foreach my $bib (@$bibs) {
622 push @new_bibs, $bib unless
623 grep { $_->eg_bib_id == $bib->id } @$already_have;
625 $bibs = [ @new_bibs ];
628 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl($mgr, undef)
629 or return $e->die_event;
632 $conn->respond($picklist->id);
635 OpenILS::Application::Acq::Order::create_lineitem(
637 "picklist" => $picklist->id,
638 "source_label" => "native-evergreen-catalog",
640 "eg_bib_id" => $_->id
644 $opts->{"limit"} ||= 10;
646 my $ses = create OpenSRF::AppSession("open-ils.search");
647 my $req = $ses->request(
648 "open-ils.search.biblio.multiclass.query.staff", $opts, $search
652 while (my $resp = $req->recv("timeout" => 60)) {
653 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl(
657 my $result = $resp->content;
658 next if not ref $result;
660 # The result object contains a whole heck of a lot more information
661 # than just bib IDs, so maybe we could tell the client something
662 # useful (progress meter at least) in the future...
665 OpenILS::Application::Acq::Order::create_lineitem(
667 "picklist" => $picklist->id,
668 "source_label" => "native-evergreen-catalog",
669 "marc" => $e->retrieve_biblio_record_entry($bib)->marc,
672 } (@{$result->{"ids"}});
679 $logger->info("created @li_ids new lineitems for picklist $picklist");
681 # new editor, but still using transaction to ensure correct retrieval
682 # in a replicated setup
683 $e = new_editor("authtoken" => $auth, xact => 1) or return $e->die_event;
684 return $e->die_event unless $e->checkauth;
685 $conn->respond($RETRIEVERS{"lineitem"}->($e, $_, $opts)) foreach @li_ids;