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 +{$op => {"transform" => "date", "value" => $value}};
64 sub prepare_acqlia_search_and {
68 foreach my $unit (@{$acqlia}) {
70 "select" => {"acqlia" => ["id"]},
72 "where" => {"-and" => [{"lineitem" => {"=" => {"+jub" => "id"}}}]}
75 # castdate not supported for acqlia fields: they're all type text
76 my ($k, $v, $fuzzy, $between, $not) = breakdown_term($unit);
77 my $point = $subquery->{"where"}->{"-and"};
80 push @$point, {"definition" => $k};
82 if ($fuzzy and not ref $v) {
83 push @$point, {"attr_value" => {"ilike" => "%" . $v . "%"}};
84 } elsif ($between and could_be_range($v)) {
85 push @$point, {"attr_value" => {"between" => $v}};
86 } elsif (check_1d_max($v)) {
87 push @$point, {"attr_value" => $v};
92 my $operator = $not ? "-not-exists" : "-exists";
93 push @phrases, {$operator => $subquery};
98 sub prepare_acqlia_search_or {
102 my $result = {"+acqlia" => {"-or" => $point}};
104 foreach my $unit (@$acqlia) {
105 # castdate not supported for acqlia fields: they're all type text
106 my ($k, $v, $fuzzy, $between, $not) = breakdown_term($unit);
108 if ($fuzzy and not ref $v) {
112 "attr_value" => {"ilike" => "%" . $v . "%"}
115 } elsif ($between and could_be_range($v)) {
118 "definition" => $k, "attr_value" => {"between" => $v}
121 } elsif (check_1d_max($v)) {
123 "-and" => {"definition" => $k, "attr_value" => $v}
129 push @$point, $not ? {"-not" => $term_clause} : $term_clause;
137 my $key = (grep { !/^__/ } keys %$term)[0];
140 $term->{"__fuzzy"} ? 1 : 0,
141 $term->{"__between"} ? 1 : 0,
142 $term->{"__not"} ? 1 : 0,
143 $term->{"__castdate"} ? 1 : 0,
144 $term->{"__gte"} ? 1 : 0,
145 $term->{"__lte"} ? 1 : 0
149 sub get_fm_links_by_hint {
151 foreach my $field (values %{$Fieldmapper::fieldmap}) {
152 return $field->{"links"} if $field->{"hint"} eq $hint;
158 my ($value, $n) = @_;
160 "=" => { transform => "lowercase", value => lc($value) }
165 {"+au$n" => {"usrname" => $value}},
166 {"+au$n" => {"first_given_name" => $lc_value}},
167 {"+au$n" => {"second_given_name" => $lc_value}},
168 {"+au$n" => {"family_name" => $lc_value}},
169 {"+ac$n" => {"barcode" => $value}}
174 # go through the terms hash, find keys that correspond to fields links
175 # to actor.usr, and rewrite the search as one that searches not by
176 # actor.usr.id but by any of these user properties: card barcode, username,
177 # given names and family name.
178 sub prepare_au_terms {
179 my ($terms, $join_num) = @_;
185 foreach my $conj (qw/-and -or/) {
186 next unless exists $terms->{$conj};
188 my @new_outer_terms = ();
189 HINT_UNIT: foreach my $hint_unit (@{$terms->{$conj}}) {
190 my $hint = (keys %$hint_unit)[0];
191 (my $plain_hint = $hint) =~ y/+//d;
192 if ($hint eq "-not") {
193 $hint_unit = $hint_unit->{$hint};
198 if (my $links = get_fm_links_by_hint($plain_hint) and
199 $plain_hint ne "acqlia") {
201 my ($attr, $value) = breakdown_term($hint_unit->{$hint});
202 if ($links->{$attr} and
203 $links->{$attr}->{"class"} eq "au") {
204 push @joins, [$plain_hint, $attr, $join_num];
205 my $au_term = gen_au_term($value, $join_num);
207 $au_term = {"-not" => $au_term};
210 push @new_outer_terms, $au_term;
212 delete $hint_unit->{$hint};
216 $hint_unit = {"-not" => $hint_unit};
219 push @new_outer_terms, $hint_unit if scalar keys %$hint_unit;
221 $terms->{$conj} = [ @new_outer_terms ];
227 my ($terms, $is_and) = @_;
229 my $conj = $is_and ? "-and" : "-or";
230 my $outer_clause = {};
232 foreach my $class (qw/acqpo acqpl acqinv jub acqlid acqlisum acqlisumi/) {
233 next if not exists $terms->{$class};
235 $outer_clause->{$conj} = [] unless $outer_clause->{$conj};
236 foreach my $unit (@{$terms->{$class}}) {
238 my ($k, $v, $fuzzy, $between, $not, $castdate, $gte, $lte) =
239 breakdown_term($unit);
242 if ($fuzzy and not ref $v) {
243 $term_clause = {$k => {"ilike" => "%" . $v . "%"}};
244 } elsif ($between and could_be_range($v)) {
245 $term_clause = {$k => {"between" => $v}};
246 } elsif (check_1d_max($v)) {
248 $v = castdate($v, $gte, $lte) if $castdate;
249 } elsif ($gte or $lte) {
250 my $op = $gte ? '>=' : '<=';
252 } elsif (not ref $v and $not) {
253 # the old way, NOT (blah.id = $v) needs to be
254 # (blah.id <> $x OR blah.id IS NULL)
255 $not = 0; # avoid the regular negative transformation
258 {"+$class" => {$k => {"!=" => $v}}},
259 {"+$class" => {$k => undef}}
263 $term_clause = {$k => $v};
268 if ($special_clause) {
269 push @{$outer_clause->{$conj}}, $special_clause;
271 my $clause = {"+" . $class => $term_clause};
272 $clause = {"-not" => $clause} if $not;
273 push @{$outer_clause->{$conj}}, $clause;
278 if ($terms->{"acqlia"}) {
279 push @{$outer_clause->{$conj}},
280 $is_and ? prepare_acqlia_search_and($terms->{"acqlia"}) :
281 prepare_acqlia_search_or($terms->{"acqlia"});
284 return undef unless scalar keys %$outer_clause;
289 my $graft_map = shift;
290 my $core_hint = shift;
293 foreach my $join (@_) {
294 my ($hint, $attr, $num) = @$join;
295 my $start = $graft_map->{$hint};
311 if ($hint eq $core_hint) {
312 $start->{"au$num"} = $clause;
314 $start->{"join"} ||= {};
315 $start->{"join"}->{"au$num"} = $clause;
323 sub build_from_clause_and_joins {
324 my ($query, $core, $and_terms, $or_terms) = @_;
328 $graft_map{$core} = $query->{from}{$core} = {};
330 my $join_type = keys(%$or_terms) ? "left" : "inner";
332 my @classes = grep { $core ne $_ } (keys(%$and_terms), keys(%$or_terms));
333 my %classes_uniq = map { $_ => 1 } @classes;
334 @classes = keys(%classes_uniq);
336 my $acqlia_join = sub {
337 return {"type" => "left", "field" => "lineitem", "fkey" => "id"};
340 foreach my $class (@classes) {
341 if ($class eq 'acqlia') {
342 if ($core eq 'acqinv') {
344 $query->{from}{$core}{acqmapinv}{join}{jub}{join}{acqlia} =
346 } elsif ($core eq 'jub') {
348 $query->{from}{$core}{acqlia} =
352 $query->{from}{$core}{jub}{join}{acqlia} =
355 } elsif ($class eq 'acqinv' or $core eq 'acqinv') {
357 $query->{from}{$core}{acqmapinv}{join}{$class} ||= {};
358 $graft_map{$class}{type} = "left"; # $join_type
360 $graft_map{$class} = $query->{from}{$core}{$class} ||= {};
361 $graft_map{$class}{type} = $join_type;
363 # without this, the SQL attempts to join on
364 # jub.order_summary, which is a virtual field.
365 $graft_map{$class}{field} = 'lineitem'
366 if $class eq 'acqlisum' or $class eq 'acqlisumi';
373 __PACKAGE__->register_method(
374 method => "unified_search",
375 api_name => "open-ils.acq.lineitem.unified_search",
378 desc => q/Returns lineitems based on flexible search terms./,
380 {desc => "Authentication token", type => "string"},
381 {desc => "Field/value pairs for AND'ing", type => "object"},
382 {desc => "Field/value pairs for OR'ing", type => "object"},
383 {desc => "Conjunction between AND pairs and OR pairs " .
384 "(can be 'and' or 'or')", type => "string"},
385 {desc => "Retrieval options (clear_marc, flesh_notes, etc) " .
386 "- XXX detail all the options",
389 return => {desc => "A stream of LIs on success, Event on failure"}
393 __PACKAGE__->register_method(
394 method => "unified_search",
395 api_name => "open-ils.acq.purchase_order.unified_search",
398 desc => q/Returns purchase orders based on flexible search terms.
399 See open-ils.acq.lineitem.unified_search/,
400 return => {desc => "A stream of POs on success, Event on failure"}
404 __PACKAGE__->register_method(
405 method => "unified_search",
406 api_name => "open-ils.acq.picklist.unified_search",
409 desc => q/Returns pick lists based on flexible search terms.
410 See open-ils.acq.lineitem.unified_search/,
411 return => {desc => "A stream of PLs on success, Event on failure"}
415 __PACKAGE__->register_method(
416 method => "unified_search",
417 api_name => "open-ils.acq.invoice.unified_search",
420 desc => q/Returns invoices lists based on flexible search terms.
421 See open-ils.acq.lineitem.unified_search/,
422 return => {desc => "A stream of invoices on success, Event on failure"}
427 my ($self, $conn, $auth, $and_terms, $or_terms, $conj, $options) = @_;
430 my $e = new_editor("authtoken" => $auth);
431 return $e->die_event unless $e->checkauth;
433 # What kind of object are we returning? Important: (\w+) had better be
434 # a legit acq classname particle, so don't register any crazy api_names.
435 my $ret_type = ($self->api_name =~ /cq.(\w+).un/)[0];
436 my $retriever = $RETRIEVERS{$ret_type};
437 my $hint = F("acq::$ret_type")->{"hint"};
439 my $select_clause = {
440 $hint => [{"column" => "id", "transform" => "distinct"}]
443 my $attr_from_filter;
444 if ($options->{"order_by"}) {
445 # What's the point of this block? When using ORDER BY in conjuction
446 # with SELECT DISTINCT, the fields present in ORDER BY have to also
447 # be in the SELECT clause. This will take _one_ such field and add
448 # it to the SELECT clause as needed.
449 my ($order_by, $class, $field);
451 ($order_by = $options->{"order_by"}->[0]) &&
452 ($class = $order_by->{"class"}) =~ /^[\da-z_]+$/ &&
453 ($field = $order_by->{"field"}) =~ /^[\da-z_]+$/
456 return new OpenILS::Event(
457 "BAD_PARAMS", "note" =>
458 q/order_by clause must be of the long form, like:
459 "order_by": [{"class": "foo", "field": "bar", "direction": "asc"}]/
464 # we can't combine distinct(id) with another select column,
465 # since the non-distinct column may arbitrarily (via hash keys)
466 # sort to the front of the final SQL, which PG will complain about.
467 $select_clause = { $hint => ["id"] };
468 $select_clause->{$class} ||= [];
469 push @{$select_clause->{$class}},
470 {column => $field, transform => 'first', aggregate => 1};
472 # when sorting by LI attr values, we have to limit
473 # to a specific type of attr value to sort on.
474 if ($class eq 'acqlia') {
475 $attr_from_filter = {
478 "attr_type" => "lineitem_marc_attr_definition",
479 "attr_name" => $options->{"order_by_attr"} || "title"
489 select => $select_clause,
490 order_by => ($options->{order_by} || {$hint => {id => {}}}),
491 offset => ($options->{offset} || 0)
494 $query->{"limit"} = $options->{"limit"} if $options->{"limit"};
496 my $graft_map = build_from_clause_and_joins(
497 $query, $hint, $and_terms, $or_terms
500 $and_terms = prepare_terms($and_terms, 1);
501 $or_terms = prepare_terms($or_terms, 0);
503 my $offset = add_au_joins($graft_map, $hint, prepare_au_terms($and_terms));
504 add_au_joins($graft_map, $hint, prepare_au_terms($or_terms, $offset));
506 # The join to acqmapinv needs to be a left join when present.
507 if ($query->{from}{$hint}{acqmapinv}) {
508 $query->{from}{$hint}{acqmapinv}{type} = "left";
511 if ($and_terms and $or_terms) {
512 $query->{"where"} = {
513 "-" . (lc $conj eq "or" ? "or" : "and") => [$and_terms, $or_terms]
515 } elsif ($and_terms) {
516 $query->{"where"} = $and_terms;
517 } elsif ($or_terms) {
518 $query->{"where"} = $or_terms;
521 return new OpenILS::Event("BAD_PARAMS", "desc" => "No usable terms");
525 # if ordering by acqlia, insert the from clause
526 # filter to limit to one type of attr.
527 if ($attr_from_filter) {
528 $query->{from}->{jub} = {} unless $query->{from}->{jub};
529 $query->{from}->{jub}->{acqlia} = $attr_from_filter;
532 my $results = $e->json_query($query) or return $e->die_event;
533 my @id_list = map { $_->{"id"} } (grep { $_->{"id"} } @$results);
536 my $resp = $retriever->($e, $_, $options);
537 next if(ref($resp) ne "Fieldmapper::acq::$ret_type");
538 $conn->respond($options->{"id_list"} ? $_ : $resp);
545 __PACKAGE__->register_method(
546 method => "bib_search",
547 api_name => "open-ils.acq.biblio.wrapped_search",
550 desc => q/Returns new lineitems for each matching bib record/,
552 {desc => "Authentication token", type => "string"},
553 {desc => "search string", type => "string"},
554 {desc => "search options", type => "object"}
556 return => {desc => "A stream of LIs on success, Event on failure"}
560 __PACKAGE__->register_method(
561 method => "bib_search",
562 api_name => "open-ils.acq.biblio.create_by_id",
565 desc => q/Returns new lineitems for each matching bib record/,
567 {desc => "Authentication token", type => "string"},
568 {desc => "list of bib IDs", type => "array"},
569 {desc => "options (for lineitem fleshing)", type => "object"}
571 return => {desc => "A stream of LIs on success, Event on failure"}
575 # This is very similar to zsearch() in Order.pm
577 my ($self, $conn, $auth, $search, $opts) = @_;
579 my $e = new_editor("authtoken" => $auth, "xact" => 1);
580 return $e->die_event unless $e->checkauth;
581 return $e->die_event unless $e->allowed("CREATE_PICKLIST");
583 my $mgr = new OpenILS::Application::Acq::BatchManager(
584 "editor" => $e, "conn" => $conn
591 if ($self->api_name =~ /create_by_id/) {
592 $search = [ sort @$search ]; # for consitency
593 my $bibs = $e->search_biblio_record_entry(
594 {"id" => $search}, {"order_by" => {"bre" => ["id"]}}
595 ) or return $e->die_event;
597 if ($opts->{"reuse_picklist"}) {
598 $picklist = $e->retrieve_acq_picklist($opts->{"reuse_picklist"}) or
599 return $e->die_event;
600 return $e->die_event unless
601 $e->allowed("UPDATE_PICKLIST", $picklist->org_unit);
603 # If we're reusing an existing picklist, we don't need to
604 # create new lineitems for any bib records for which we already
606 my $already_have = $e->search_acq_lineitem({
607 "picklist" => $picklist->id,
608 "eg_bib_id" => [ map { $_->id } @$bibs ]
609 }) or return $e->die_event;
611 # So in that case we a) save the lineitem id's of the relevant
612 # items that already exist so that we can return those items later,
613 # and b) remove the bib id's in question from our list of bib
614 # id's to lineitemize.
615 if (@$already_have) {
616 push @li_ids, $_->id foreach (@$already_have);
618 foreach my $bib (@$bibs) {
619 push @new_bibs, $bib unless
620 grep { $_->eg_bib_id == $bib->id } @$already_have;
622 $bibs = [ @new_bibs ];
625 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl($mgr, undef)
626 or return $e->die_event;
629 $conn->respond($picklist->id);
632 OpenILS::Application::Acq::Order::create_lineitem(
634 "picklist" => $picklist->id,
635 "source_label" => "native-evergreen-catalog",
637 "eg_bib_id" => $_->id
641 $opts->{"limit"} ||= 10;
643 my $ses = create OpenSRF::AppSession("open-ils.search");
644 my $req = $ses->request(
645 "open-ils.search.biblio.multiclass.query.staff", $opts, $search
649 while (my $resp = $req->recv("timeout" => 60)) {
650 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl(
654 my $result = $resp->content;
655 next if not ref $result;
657 # The result object contains a whole heck of a lot more information
658 # than just bib IDs, so maybe we could tell the client something
659 # useful (progress meter at least) in the future...
662 OpenILS::Application::Acq::Order::create_lineitem(
664 "picklist" => $picklist->id,
665 "source_label" => "native-evergreen-catalog",
666 "marc" => $e->retrieve_biblio_record_entry($bib)->marc,
669 } (@{$result->{"ids"}});
676 $logger->info("created @li_ids new lineitems for picklist $picklist");
678 # new editor, but still using transaction to ensure correct retrieval
679 # in a replicated setup
680 $e = new_editor("authtoken" => $auth, xact => 1) or return $e->die_event;
681 return $e->die_event unless $e->checkauth;
682 $conn->respond($RETRIEVERS{"lineitem"}->($e, $_, $opts)) foreach @li_ids;