]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Search.pm
ACQ invoice inline lineitem search and add
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Acq / Search.pm
1 package OpenILS::Application::Acq::Search;
2 use base "OpenILS::Application";
3
4 use strict;
5 use warnings;
6
7 use OpenSRF::AppSession;
8 use OpenSRF::Utils::Logger qw/:logger/;
9 use OpenILS::Event;
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;
17
18 my %RETRIEVERS = (
19     "lineitem" =>
20         \&{"OpenILS::Application::Acq::Lineitem::retrieve_lineitem_impl"},
21     "picklist" =>
22         \&{"OpenILS::Application::Acq::Picklist::retrieve_picklist_impl"},
23     "purchase_order" => \&{
24         "OpenILS::Application::Acq::Financials::retrieve_purchase_order_impl"
25     },
26     "invoice" => \&{
27         "OpenILS::Application::Acq::Invoice::fetch_invoice_impl"
28     },
29 );
30
31 sub F { $Fieldmapper::fieldmap->{"Fieldmapper::" . $_[0]}; }
32
33 # This subroutine returns 1 if the argument is a) a scalar OR
34 # b) an array of ONLY scalars. Otherwise it returns 0.
35 sub check_1d_max {
36     my ($o) = @_;
37     return 1 unless ref $o;
38     if (ref($o) eq "ARRAY") {
39         foreach (@$o) { return 0 if ref $_; }
40         return 1;
41     }
42     0;
43 }
44
45 # Returns 1 if and only if argument is an array of exactly two scalars.
46 sub could_be_range {
47     my ($o) = @_;
48     if (ref $o eq "ARRAY") {
49         return 1 if (scalar(@$o) == 2 && (!ref $o->[0] && !ref $o->[1]));
50     }
51     0;
52 }
53
54 sub castdate {
55     my ($value, $gte, $lte) = @_;
56
57     my $op = "=";
58     $op = ">=" if $gte;
59     $op = "<=" if $lte;
60
61     +{$op => {"transform" => "date", "value" => $value}};
62 }
63
64 sub prepare_acqlia_search_and {
65     my ($acqlia) = @_;
66
67     my @phrases = ();
68     foreach my $unit (@{$acqlia}) {
69         my $subquery = {
70             "select" => {"acqlia" => ["id"]},
71             "from" => "acqlia",
72             "where" => {"-and" => [{"lineitem" => {"=" => {"+jub" => "id"}}}]}
73         };
74
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"};
78         my $term_clause;
79
80         push @$point, {"definition" => $k};
81
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};
88         } else {
89             next;
90         }
91
92         my $operator = $not ? "-not-exists" : "-exists";
93         push @phrases, {$operator => $subquery};
94     }
95     @phrases;
96 }
97
98 sub prepare_acqlia_search_or {
99     my ($acqlia) = @_;
100
101     my $point = [];
102     my $result = {"+acqlia" => {"-or" => $point}};
103
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);
107         my $term_clause;
108         if ($fuzzy and not ref $v) {
109             $term_clause = {
110                 "-and" => {
111                     "definition" => $k,
112                     "attr_value" => {"ilike" => "%" . $v . "%"}
113                 }
114             };
115         } elsif ($between and could_be_range($v)) {
116             $term_clause = {
117                 "-and" => {
118                     "definition" => $k, "attr_value" => {"between" => $v}
119                 }
120             };
121         } elsif (check_1d_max($v)) {
122             $term_clause = {
123                 "-and" => {"definition" => $k, "attr_value" => $v}
124             };
125         } else {
126             next;
127         }
128
129         push @$point, $not ? {"-not" => $term_clause} : $term_clause;
130     }
131     $result;
132 }
133
134 sub breakdown_term {
135     my ($term) = @_;
136
137     my $key = (grep { !/^__/ } keys %$term)[0];
138     (
139         $key, $term->{$key},
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
146     );
147 }
148
149 sub get_fm_links_by_hint {
150     my ($hint) = @_;
151     foreach my $field (values %{$Fieldmapper::fieldmap}) {
152         return $field->{"links"} if $field->{"hint"} eq $hint;
153     }
154     undef;
155 }
156
157 sub gen_au_term {
158     my ($value, $n) = @_;
159     my $lc_value = {
160         "=" => { transform => "lowercase", value => lc($value) }
161     };
162
163     +{
164         "-or" => [
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}}
170         ]
171     };
172 }
173
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) = @_;
180
181     my @joins = ();
182     my $nots = 0;
183     $join_num ||= 0;
184
185     foreach my $conj (qw/-and -or/) {
186         next unless exists $terms->{$conj};
187
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};
194                 $nots++;
195                 redo HINT_UNIT;
196             }
197
198             if (my $links = get_fm_links_by_hint($plain_hint) and
199                 $plain_hint ne "acqlia") {
200                 my @new_terms = ();
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);
206                     if ($nots > 0) {
207                         $au_term = {"-not" => $au_term};
208                         $nots--;
209                     }
210                     push @new_outer_terms, $au_term;
211                     $join_num++;
212                     delete $hint_unit->{$hint};
213                 }
214             }
215             if ($nots > 0) {
216                 $hint_unit = {"-not" => $hint_unit};
217                 $nots--;
218             }
219             push @new_outer_terms, $hint_unit if scalar keys %$hint_unit;
220         }
221         $terms->{$conj} = [ @new_outer_terms ];
222     }
223     @joins;
224 }
225
226 sub prepare_terms {
227     my ($terms, $is_and) = @_;
228
229     my $conj = $is_and ? "-and" : "-or";
230     my $outer_clause = {};
231
232     foreach my $class (qw/acqpo acqpl acqinv jub acqlid acqlisum acqlisumi/) {
233         next if not exists $terms->{$class};
234
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);
239
240             my $term_clause;
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)) {
246                 if ($castdate) {
247                     $v = castdate($v, $gte, $lte) if $castdate;
248                 } elsif ($gte or $lte) {
249                     my $op = $gte ? '>=' : '<=';
250                     $v = {$op => $v};
251                 }
252                 $term_clause = {$k => $v};
253             } else {
254                 next;
255             }
256
257             my $clause = {"+" . $class => $term_clause};
258             $clause = {"-not" => $clause} if $not;
259             push @{$outer_clause->{$conj}}, $clause;
260         }
261     }
262
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"});
267     }
268
269     return undef unless scalar keys %$outer_clause;
270     $outer_clause;
271 }
272
273 sub add_au_joins {
274     my $graft_map = shift;
275     my $core_hint = shift;
276
277     my $n = 0;
278     foreach my $join (@_) {
279         my ($hint, $attr, $num) = @$join;
280         my $start = $graft_map->{$hint};
281         my $clause = {
282             "class" => "au",
283             "type" => "left",
284             "field" => "id",
285             "fkey" => $attr,
286             "join" => {
287                 "ac$num" => {
288                     "class" => "ac",
289                     "type" => "left",
290                     "field" => "id",
291                     "fkey" => "card"
292                 }
293             }
294         };
295
296         if ($hint eq $core_hint) {
297             $start->{"au$num"} = $clause;
298         } else {
299             $start->{"join"} ||= {};
300             $start->{"join"}->{"au$num"} = $clause;
301         }
302
303         $n++;
304     }
305     $n;
306 }
307
308 sub build_from_clause_and_joins {
309     my ($query, $core, $and_terms, $or_terms) = @_;
310
311     my %graft_map = ();
312
313     $graft_map{$core} = $query->{from}{$core} = {};
314
315     my $join_type = keys(%$or_terms) ? "left" : "inner";
316
317     my @classes = grep { $core ne $_ } (keys(%$and_terms), keys(%$or_terms));
318     my %classes_uniq = map { $_ => 1 } @classes;
319     @classes = keys(%classes_uniq);
320
321     my $acqlia_join = sub {
322         return {"type" => "left", "field" => "lineitem", "fkey" => "id"};
323     };
324
325     foreach my $class (@classes) {
326         if ($class eq 'acqlia') {
327             if ($core eq 'acqinv') {
328                 $graft_map{acqlia} =
329                     $query->{from}{$core}{acqmapinv}{join}{jub}{join}{acqlia} =
330                     $acqlia_join->();
331             } elsif ($core eq 'jub') {
332                 $graft_map{acqlia} = 
333                     $query->{from}{$core}{acqlia} =
334                     $acqlia_join->();
335             } else {
336                 $graft_map{acqlia} = 
337                     $query->{from}{$core}{jub}{join}{acqlia} =
338                     $acqlia_join->();
339             }
340         } elsif ($class eq 'acqinv' or $core eq 'acqinv') {
341             $graft_map{$class} =
342                 $query->{from}{$core}{acqmapinv}{join}{$class} ||= {};
343             $graft_map{$class}{type} = $join_type;
344         } else {
345             $graft_map{$class} = $query->{from}{$core}{$class} ||= {};
346             $graft_map{$class}{type} = $join_type;
347
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';
352         }
353     }
354
355     return \%graft_map;
356 }
357
358 __PACKAGE__->register_method(
359     method    => "unified_search",
360     api_name  => "open-ils.acq.lineitem.unified_search",
361     stream    => 1,
362     signature => {
363         desc   => q/Returns lineitems based on flexible search terms./,
364         params => [
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",
372                 type => "object"}
373         ],
374         return => {desc => "A stream of LIs on success, Event on failure"}
375     }
376 );
377
378 __PACKAGE__->register_method(
379     method    => "unified_search",
380     api_name  => "open-ils.acq.purchase_order.unified_search",
381     stream    => 1,
382     signature => {
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"}
386     }
387 );
388
389 __PACKAGE__->register_method(
390     method    => "unified_search",
391     api_name  => "open-ils.acq.picklist.unified_search",
392     stream    => 1,
393     signature => {
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"}
397     }
398 );
399
400 __PACKAGE__->register_method(
401     method    => "unified_search",
402     api_name  => "open-ils.acq.invoice.unified_search",
403     stream    => 1,
404     signature => {
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"}
408     }
409 );
410
411 sub unified_search {
412     my ($self, $conn, $auth, $and_terms, $or_terms, $conj, $options) = @_;
413     $options ||= {};
414
415     my $e = new_editor("authtoken" => $auth);
416     return $e->die_event unless $e->checkauth;
417
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"};
423
424     my $select_clause = {
425         $hint => [{"column" => "id", "transform" => "distinct"}]
426     };
427
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);
435         unless (
436             ($order_by = $options->{"order_by"}->[0]) &&
437             ($class = $order_by->{"class"}) =~ /^[\da-z_]+$/ &&
438             ($field = $order_by->{"field"}) =~ /^[\da-z_]+$/
439         ) {
440             $e->disconnect;
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"}]/
445             );
446
447         } else {
448
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};
456
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 = {
461                     "fkey" => "id",
462                     "filter" => {
463                         "attr_type" => "lineitem_marc_attr_definition",
464                         "attr_name" => $options->{"order_by_attr"} || "title"
465                     },
466                     "type" => "left",
467                     "field" =>"lineitem"
468                 };
469             }
470         }
471     }
472
473     my $query = {
474         select => $select_clause,
475         order_by => ($options->{order_by} || {$hint => {id => {}}}),
476         offset => ($options->{offset} || 0)
477     };
478
479     $query->{"limit"} = $options->{"limit"} if $options->{"limit"};
480
481     my $graft_map = build_from_clause_and_joins(
482         $query, $hint, $and_terms, $or_terms
483     );
484
485     $and_terms = prepare_terms($and_terms, 1);
486     $or_terms = prepare_terms($or_terms, 0);
487
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));
490
491     if ($and_terms and $or_terms) {
492         $query->{"where"} = {
493             "-" . (lc $conj eq "or" ? "or" : "and") => [$and_terms, $or_terms]
494         };
495     } elsif ($and_terms) {
496         $query->{"where"} = $and_terms;
497     } elsif ($or_terms) {
498         $query->{"where"} = $or_terms;
499     } else {
500         $e->disconnect;
501         return new OpenILS::Event("BAD_PARAMS", "desc" => "No usable terms");
502     }
503
504
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;
510     }
511
512     my $results = $e->json_query($query) or return $e->die_event;
513     my @id_list = map { $_->{"id"} } (grep { $_->{"id"} } @$results);
514
515     if ($options->{"id_list"}) {
516         $conn->respond($_) foreach @id_list;
517     } else {
518         foreach(@id_list){
519             my $resp = $retriever->($e, $_, $options);
520             next if(ref($resp) ne "Fieldmapper::acq::$ret_type");
521             $conn->respond($resp);
522         }
523     }
524
525     $e->disconnect;
526     undef;
527 }
528
529 __PACKAGE__->register_method(
530     method    => "bib_search",
531     api_name  => "open-ils.acq.biblio.wrapped_search",
532     stream    => 1,
533     signature => {
534         desc   => q/Returns new lineitems for each matching bib record/,
535         params => [
536             {desc => "Authentication token", type => "string"},
537             {desc => "search string", type => "string"},
538             {desc => "search options", type => "object"}
539         ],
540         return => {desc => "A stream of LIs on success, Event on failure"}
541     }
542 );
543
544 __PACKAGE__->register_method(
545     method    => "bib_search",
546     api_name  => "open-ils.acq.biblio.create_by_id",
547     stream    => 1,
548     signature => {
549         desc   => q/Returns new lineitems for each matching bib record/,
550         params => [
551             {desc => "Authentication token", type => "string"},
552             {desc => "list of bib IDs", type => "array"},
553             {desc => "options (for lineitem fleshing)", type => "object"}
554         ],
555         return => {desc => "A stream of LIs on success, Event on failure"}
556     }
557 );
558
559 # This is very similar to zsearch() in Order.pm
560 sub bib_search {
561     my ($self, $conn, $auth, $search, $opts) = @_;
562
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");
566
567     my $mgr = new OpenILS::Application::Acq::BatchManager(
568         "editor" => $e, "conn" => $conn
569     );
570
571     $opts ||= {};
572
573     my $picklist;
574     my @li_ids = ();
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;
580
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);
586
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
589
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;
594          
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);
601                 my @new_bibs = ();
602                 foreach my $bib (@$bibs) {
603                     push @new_bibs, $bib unless
604                         grep { $_->eg_bib_id == $bib->id } @$already_have;
605                 }
606                 $bibs = [ @new_bibs ];
607             }
608         } else {
609             $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl($mgr, undef)
610                 or return $e->die_event;
611         }
612
613         $conn->respond($picklist->id);
614
615         push @li_ids, map {
616             OpenILS::Application::Acq::Order::create_lineitem(
617                 $mgr,
618                 "picklist" => $picklist->id,
619                 "source_label" => "native-evergreen-catalog",
620                 "marc" => $_->marc,
621                 "eg_bib_id" => $_->id
622             )->id;
623         } (@$bibs);
624     } else {
625         $opts->{"limit"} ||= 10;
626
627         my $ses = create OpenSRF::AppSession("open-ils.search");
628         my $req = $ses->request(
629             "open-ils.search.biblio.multiclass.query.staff", $opts, $search
630         );
631
632         my $count = 0;
633         while (my $resp = $req->recv("timeout" => 60)) {
634             $picklist = OpenILS::Application::Acq::Order::zsearch_build_pl(
635                 $mgr, undef
636             ) unless $count++;
637
638             my $result = $resp->content;
639             next if not ref $result;
640
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...
644             push @li_ids, map {
645                 my $bib = $_->[0];
646                 OpenILS::Application::Acq::Order::create_lineitem(
647                     $mgr,
648                     "picklist" => $picklist->id,
649                     "source_label" => "native-evergreen-catalog",
650                     "marc" => $e->retrieve_biblio_record_entry($bib)->marc,
651                     "eg_bib_id" => $bib
652                 )->id;
653             } (@{$result->{"ids"}});
654         }
655         $ses->disconnect;
656     }
657
658     $e->commit;
659
660     $logger->info("created @li_ids new lineitems for picklist $picklist");
661
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;
667     $e->rollback;
668     $e->disconnect;
669
670     undef;
671 }
672
673 1;