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}}) {
237 my ($k, $v, $fuzzy, $between, $not, $castdate, $gte, $lte) =
238 breakdown_term($unit);
241 if ($fuzzy and not ref $v) {
242 $term_clause = {$k => {"ilike" => "%" . $v . "%"}};
243 } elsif ($between and could_be_range($v)) {
244 $term_clause = {$k => {"between" => $v}};
245 } elsif (check_1d_max($v)) {
247 $v = castdate($v, $gte, $lte) if $castdate;
248 } elsif ($gte or $lte) {
249 my $op = $gte ? '>=' : '<=';
252 $term_clause = {$k => $v};
257 my $clause = {"+" . $class => $term_clause};
258 $clause = {"-not" => $clause} if $not;
259 push @{$outer_clause->{$conj}}, $clause;
263 if ($terms->{"acqlia"}) {
264 push @{$outer_clause->{$conj}},
265 $is_and ? prepare_acqlia_search_and($terms->{"acqlia"}) :
266 prepare_acqlia_search_or($terms->{"acqlia"});
269 return undef unless scalar keys %$outer_clause;
274 my $graft_map = shift;
275 my $core_hint = shift;
278 foreach my $join (@_) {
279 my ($hint, $attr, $num) = @$join;
280 my $start = $graft_map->{$hint};
296 if ($hint eq $core_hint) {
297 $start->{"au$num"} = $clause;
299 $start->{"join"} ||= {};
300 $start->{"join"}->{"au$num"} = $clause;
308 sub build_from_clause_and_joins {
309 my ($query, $core, $and_terms, $or_terms) = @_;
313 $graft_map{$core} = $query->{from}{$core} = {};
315 my $join_type = keys(%$or_terms) ? "left" : "inner";
317 my @classes = grep { $core ne $_ } (keys(%$and_terms), keys(%$or_terms));
318 my %classes_uniq = map { $_ => 1 } @classes;
319 @classes = keys(%classes_uniq);
321 my $acqlia_join = sub {
322 return {"type" => "left", "field" => "lineitem", "fkey" => "id"};
325 foreach my $class (@classes) {
326 if ($class eq 'acqlia') {
327 if ($core eq 'acqinv') {
329 $query->{from}{$core}{acqmapinv}{join}{jub}{join}{acqlia} =
331 } elsif ($core eq 'jub') {
333 $query->{from}{$core}{acqlia} =
337 $query->{from}{$core}{jub}{join}{acqlia} =
340 } elsif ($class eq 'acqinv' or $core eq 'acqinv') {
342 $query->{from}{$core}{acqmapinv}{join}{$class} ||= {};
343 $graft_map{$class}{type} = $join_type;
345 $graft_map{$class} = $query->{from}{$core}{$class} ||= {};
346 $graft_map{$class}{type} = $join_type;
348 # without this, the SQL attempts to join on
349 # jub.order_summary, which is a virtual field.
350 $graft_map{$class}{field} = 'lineitem'
351 if $class eq 'acqlisum' or $class eq 'acqlisumi';
358 __PACKAGE__->register_method(
359 method => "unified_search",
360 api_name => "open-ils.acq.lineitem.unified_search",
363 desc => q/Returns lineitems based on flexible search terms./,
365 {desc => "Authentication token", type => "string"},
366 {desc => "Field/value pairs for AND'ing", type => "object"},
367 {desc => "Field/value pairs for OR'ing", type => "object"},
368 {desc => "Conjunction between AND pairs and OR pairs " .
369 "(can be 'and' or 'or')", type => "string"},
370 {desc => "Retrieval options (clear_marc, flesh_notes, etc) " .
371 "- XXX detail all the options",
374 return => {desc => "A stream of LIs on success, Event on failure"}
378 __PACKAGE__->register_method(
379 method => "unified_search",
380 api_name => "open-ils.acq.purchase_order.unified_search",
383 desc => q/Returns purchase orders based on flexible search terms.
384 See open-ils.acq.lineitem.unified_search/,
385 return => {desc => "A stream of POs on success, Event on failure"}
389 __PACKAGE__->register_method(
390 method => "unified_search",
391 api_name => "open-ils.acq.picklist.unified_search",
394 desc => q/Returns pick lists based on flexible search terms.
395 See open-ils.acq.lineitem.unified_search/,
396 return => {desc => "A stream of PLs on success, Event on failure"}
400 __PACKAGE__->register_method(
401 method => "unified_search",
402 api_name => "open-ils.acq.invoice.unified_search",
405 desc => q/Returns invoices lists based on flexible search terms.
406 See open-ils.acq.lineitem.unified_search/,
407 return => {desc => "A stream of invoices on success, Event on failure"}
412 my ($self, $conn, $auth, $and_terms, $or_terms, $conj, $options) = @_;
415 my $e = new_editor("authtoken" => $auth);
416 return $e->die_event unless $e->checkauth;
418 # What kind of object are we returning? Important: (\w+) had better be
419 # a legit acq classname particle, so don't register any crazy api_names.
420 my $ret_type = ($self->api_name =~ /cq.(\w+).un/)[0];
421 my $retriever = $RETRIEVERS{$ret_type};
422 my $hint = F("acq::$ret_type")->{"hint"};
424 my $select_clause = {
425 $hint => [{"column" => "id", "transform" => "distinct"}]
428 my $attr_from_filter;
429 if ($options->{"order_by"}) {
430 # What's the point of this block? When using ORDER BY in conjuction
431 # with SELECT DISTINCT, the fields present in ORDER BY have to also
432 # be in the SELECT clause. This will take _one_ such field and add
433 # it to the SELECT clause as needed.
434 my ($order_by, $class, $field);
436 ($order_by = $options->{"order_by"}->[0]) &&
437 ($class = $order_by->{"class"}) =~ /^[\da-z_]+$/ &&
438 ($field = $order_by->{"field"}) =~ /^[\da-z_]+$/
441 return new OpenILS::Event(
442 "BAD_PARAMS", "note" =>
443 q/order_by clause must be of the long form, like:
444 "order_by": [{"class": "foo", "field": "bar", "direction": "asc"}]/
449 # we can't combine distinct(id) with another select column,
450 # since the non-distinct column may arbitrarily (via hash keys)
451 # sort to the front of the final SQL, which PG will complain about.
452 $select_clause = { $hint => ["id"] };
453 $select_clause->{$class} ||= [];
454 push @{$select_clause->{$class}},
455 {column => $field, transform => 'first', aggregate => 1};
457 # when sorting by LI attr values, we have to limit
458 # to a specific type of attr value to sort on.
459 if ($class eq 'acqlia') {
460 $attr_from_filter = {
463 "attr_type" => "lineitem_marc_attr_definition",
464 "attr_name" => $options->{"order_by_attr"} || "title"
474 select => $select_clause,
475 order_by => ($options->{order_by} || {$hint => {id => {}}}),
476 offset => ($options->{offset} || 0)
479 $query->{"limit"} = $options->{"limit"} if $options->{"limit"};
481 my $graft_map = build_from_clause_and_joins(
482 $query, $hint, $and_terms, $or_terms
485 $and_terms = prepare_terms($and_terms, 1);
486 $or_terms = prepare_terms($or_terms, 0);
488 my $offset = add_au_joins($graft_map, $hint, prepare_au_terms($and_terms));
489 add_au_joins($graft_map, $hint, prepare_au_terms($or_terms, $offset));
491 if ($and_terms and $or_terms) {
492 $query->{"where"} = {
493 "-" . (lc $conj eq "or" ? "or" : "and") => [$and_terms, $or_terms]
495 } elsif ($and_terms) {
496 $query->{"where"} = $and_terms;
497 } elsif ($or_terms) {
498 $query->{"where"} = $or_terms;
501 return new OpenILS::Event("BAD_PARAMS", "desc" => "No usable terms");
505 # if ordering by acqlia, insert the from clause
506 # filter to limit to one type of attr.
507 if ($attr_from_filter) {
508 $query->{from}->{jub} = {} unless $query->{from}->{jub};
509 $query->{from}->{jub}->{acqlia} = $attr_from_filter;
512 my $results = $e->json_query($query) or return $e->die_event;
513 my @id_list = map { $_->{"id"} } (grep { $_->{"id"} } @$results);
515 if ($options->{"id_list"}) {
516 $conn->respond($_) foreach @id_list;
519 my $resp = $retriever->($e, $_, $options);
520 next if(ref($resp) ne "Fieldmapper::acq::$ret_type");
521 $conn->respond($resp);
529 __PACKAGE__->register_method(
530 method => "bib_search",
531 api_name => "open-ils.acq.biblio.wrapped_search",
534 desc => q/Returns new lineitems for each matching bib record/,
536 {desc => "Authentication token", type => "string"},
537 {desc => "search string", type => "string"},
538 {desc => "search options", type => "object"}
540 return => {desc => "A stream of LIs on success, Event on failure"}
544 __PACKAGE__->register_method(
545 method => "bib_search",
546 api_name => "open-ils.acq.biblio.create_by_id",
549 desc => q/Returns new lineitems for each matching bib record/,
551 {desc => "Authentication token", type => "string"},
552 {desc => "list of bib IDs", type => "array"},
553 {desc => "options (for lineitem fleshing)", type => "object"}
555 return => {desc => "A stream of LIs on success, Event on failure"}
559 # This is very similar to zsearch() in Order.pm
561 my ($self, $conn, $auth, $search, $opts) = @_;
563 my $e = new_editor("authtoken" => $auth, "xact" => 1);
564 return $e->die_event unless $e->checkauth;
565 return $e->die_event unless $e->allowed("CREATE_PICKLIST");
567 my $mgr = new OpenILS::Application::Acq::BatchManager(
568 "editor" => $e, "conn" => $conn
575 if ($self->api_name =~ /create_by_id/) {
576 $search = [ sort @$search ]; # for consitency
577 my $bibs = $e->search_biblio_record_entry(
578 {"id" => $search}, {"order_by" => {"bre" => ["id"]}}
579 ) or return $e->die_event;
581 if ($opts->{"reuse_picklist"}) {
582 $picklist = $e->retrieve_acq_picklist($opts->{"reuse_picklist"}) or
583 return $e->die_event;
584 return $e->die_event unless
585 $e->allowed("UPDATE_PICKLIST", $picklist->org_unit);
587 # If we're reusing an existing picklist, we don't need to
588 # create new lineitems for any bib records for which we already
590 my $already_have = $e->search_acq_lineitem({
591 "picklist" => $picklist->id,
592 "eg_bib_id" => [ map { $_->id } @$bibs ]
593 }) or return $e->die_event;
595 # So in that case we a) save the lineitem id's of the relevant
596 # items that already exist so that we can return those items later,
597 # and b) remove the bib id's in question from our list of bib
598 # id's to lineitemize.
599 if (@$already_have) {
600 push @li_ids, $_->id foreach (@$already_have);
602 foreach my $bib (@$bibs) {
603 push @new_bibs, $bib unless
604 grep { $_->eg_bib_id == $bib->id } @$already_have;
606 $bibs = [ @new_bibs ];
609 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl($mgr, undef)
610 or return $e->die_event;
613 $conn->respond($picklist->id);
616 OpenILS::Application::Acq::Order::create_lineitem(
618 "picklist" => $picklist->id,
619 "source_label" => "native-evergreen-catalog",
621 "eg_bib_id" => $_->id
625 $opts->{"limit"} ||= 10;
627 my $ses = create OpenSRF::AppSession("open-ils.search");
628 my $req = $ses->request(
629 "open-ils.search.biblio.multiclass.query.staff", $opts, $search
633 while (my $resp = $req->recv("timeout" => 60)) {
634 $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl(
638 my $result = $resp->content;
639 next if not ref $result;
641 # The result object contains a whole heck of a lot more information
642 # than just bib IDs, so maybe we could tell the client something
643 # useful (progress meter at least) in the future...
646 OpenILS::Application::Acq::Order::create_lineitem(
648 "picklist" => $picklist->id,
649 "source_label" => "native-evergreen-catalog",
650 "marc" => $e->retrieve_biblio_record_entry($bib)->marc,
653 } (@{$result->{"ids"}});
660 $logger->info("created @li_ids new lineitems for picklist $picklist");
662 # new editor, but still using transaction to ensure correct retrieval
663 # in a replicated setup
664 $e = new_editor("authtoken" => $auth, xact => 1) or return $e->die_event;
665 return $e->die_event unless $e->checkauth;
666 $conn->respond($RETRIEVERS{"lineitem"}->($e, $_, $opts)) foreach @li_ids;