026131b17622c60af35519e7f8ab5b33207f24e3
[kivitendo-erp.git] / SL / DB / Helper / LinkedRecords.pm
1 package SL::DB::Helper::LinkedRecords;
2
3 use strict;
4
5 require Exporter;
6 our @ISA    = qw(Exporter);
7 our @EXPORT = qw(linked_records link_to_record);
8
9 use Carp;
10 use Sort::Naturally;
11 use SL::DBUtils;
12
13 use SL::DB::Helper::Mappings;
14 use SL::DB::RecordLink;
15
16 sub linked_records {
17   my ($self, %params) = @_;
18
19   my %sort_spec       = ( by  => delete($params{sort_by}),
20                           dir => delete($params{sort_dir}) );
21   my $filter          =  delete $params{filter};
22
23   my $records         = _linked_records_implementation($self, %params);
24   $records            = filter_linked_records($self, $filter, @{ $records })                       if $filter;
25   $records            = sort_linked_records($self, $sort_spec{by}, $sort_spec{dir}, @{ $records }) if $sort_spec{by};
26
27   return $records;
28 }
29
30 sub _linked_records_implementation {
31   my $self     = shift;
32   my %params   = @_;
33
34   my $wanted   = $params{direction};
35
36   if (!$wanted) {
37     if ($params{to} && $params{from}) {
38       $wanted = 'both';
39     } elsif ($params{to}) {
40       $wanted = 'to';
41     } elsif ($params{from}) {
42       $wanted = 'from';
43     } else {
44       $wanted = 'both';
45     }
46   }
47
48   if ($wanted eq 'both') {
49     my $both       = delete($params{both});
50     my %from_to    = ( from => delete($params{from}) || $both,
51                        to   => delete($params{to})   || $both);
52
53     my @records    = (@{ _linked_records_implementation($self, %params, direction => 'from', from => $from_to{from}) },
54                       @{ _linked_records_implementation($self, %params, direction => 'to',   to   => $from_to{to}  ) });
55
56     my %record_map = map { ( ref($_) . $_->id => $_ ) } @records;
57
58     return [ values %record_map ];
59   }
60
61   if ($params{via}) {
62     croak("Cannot use 'via' without '${wanted}_table'")             if !$params{$wanted};
63     croak("Cannot use 'via' with '${wanted}_table' being an array") if ref $params{$wanted};
64   }
65
66   my $myself           = $wanted eq 'from' ? 'to' : $wanted eq 'to' ? 'from' : croak("Invalid parameter `direction'");
67   my $my_table         = SL::DB::Helper::Mappings::get_table_for_package(ref($self));
68
69   my $sub_wanted_table = "${wanted}_table";
70   my $sub_wanted_id    = "${wanted}_id";
71
72   my ($wanted_classes, $wanted_tables);
73   if ($params{$wanted}) {
74     $wanted_classes = ref($params{$wanted}) eq 'ARRAY' ? $params{$wanted} : [ $params{$wanted} ];
75     $wanted_tables  = [ map { SL::DB::Helper::Mappings::get_table_for_package($_) || croak("Invalid parameter `${wanted}'") } @{ $wanted_classes } ];
76   }
77
78   my @get_objects_query = ref($params{query}) eq 'ARRAY' ? @{ $params{query} } : ();
79   my $get_objects       = sub {
80     my ($link)        = @_;
81     my $manager_class = SL::DB::Helper::Mappings::get_manager_package_for_table($link->$sub_wanted_table);
82     my $object_class  = SL::DB::Helper::Mappings::get_package_for_table($link->$sub_wanted_table);
83     eval "require " . $object_class . "; 1;";
84     return map {
85       $_->{_record_link_direction} = $wanted;
86       $_->{_record_link}           = $link;
87       $_
88     } @{
89       $manager_class->get_all(
90         query         => [ id => $link->$sub_wanted_id, @get_objects_query ],
91         (with_objects => $params{with_objects}) x !!$params{with_objects},
92         inject_results => 1,
93       )
94     };
95   };
96
97   # If no 'via' is given then use a simple(r) method for querying the wanted objects.
98   if (!$params{via} && !$params{recursive}) {
99     my @query = ( "${myself}_table" => $my_table,
100                   "${myself}_id"    => $self->id );
101     push @query, ( "${wanted}_table" => $wanted_tables ) if $wanted_tables;
102
103     return [ map { $get_objects->($_) } @{ SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]) } ];
104   }
105
106   # More complex handling for the 'via' case.
107   if ($params{via}) {
108     my @sources = ( $self );
109     my @targets = map { SL::DB::Helper::Mappings::get_table_for_package($_) } @{ ref($params{via}) ? $params{via} : [ $params{via} ] };
110     push @targets, @{ $wanted_tables } if $wanted_tables;
111
112     my %seen = map { ($_->meta->table . $_->id => 1) } @sources;
113
114     while (@targets) {
115       my @new_sources = @sources;
116       foreach my $src (@sources) {
117         my @query = ( "${myself}_table" => $src->meta->table,
118                       "${myself}_id"    => $src->id,
119                       "${wanted}_table" => \@targets );
120         push @new_sources,
121              map  { $get_objects->($_) }
122              grep { !$seen{$_->$sub_wanted_table . $_->$sub_wanted_id} }
123              @{ SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]) };
124       }
125
126       @sources = @new_sources;
127       %seen    = map { ($_->meta->table . $_->id => 1) } @sources;
128       shift @targets;
129     }
130
131     my %wanted_tables_map = map  { ($_ => 1) } @{ $wanted_tables };
132     return [ grep { $wanted_tables_map{$_->meta->table} } @sources ];
133   }
134
135   # And lastly recursive mode
136   if ($params{recursive}) {
137     # don't use rose retrieval here. too slow.
138     # instead use recursive sql to get all the linked record_links entrys, and retrieve the objects from there
139     my $query = <<"";
140       WITH RECURSIVE record_links_rec_${wanted}(id, from_table, from_id, to_table, to_id, depth, path, cycle) AS (
141         SELECT id, from_table, from_id, to_table, to_id,
142           1, ARRAY[id], false
143         FROM record_links
144         WHERE ${myself}_id = ? and ${myself}_table = ?
145       UNION ALL
146         SELECT rl.id, rl.from_table, rl.from_id, rl.to_table, rl.to_id,
147           rlr.depth + 1, path || rl.id, rl.id = ANY(path)
148         FROM record_links rl, record_links_rec_${wanted} rlr
149         WHERE rlr.${wanted}_id = rl.${myself}_id AND rlr.${wanted}_table = rl.${myself}_table AND NOT cycle
150       )
151       SELECT DISTINCT ON (${wanted}_table, ${wanted}_id)
152         id, from_table, from_id, to_table, to_id, path, depth FROM record_links_rec_${wanted}
153       WHERE NOT cycle
154       ORDER BY ${wanted}_table, ${wanted}_id, depth ASC;
155
156     my $links     = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id, $self->meta->table);
157
158     return [] unless @$links;
159
160     my $link_objs = SL::DB::Manager::RecordLink->get_all(query => [ id => [ map { $_->{id} } @$links ] ]);
161     my @objects = map { $get_objects->($_) } @$link_objs;
162
163     if ($params{save_path}) {
164        my %links_by_id = map { $_->{id} => $_ } @$links;
165        for (@objects) {
166          my $link = $links_by_id{$_->{_record_link}->id};
167          my $intermediate_links = SL::DB::Manager::RecordLink->get_all(query => [ id => $link->{path} ]);
168          $_->{_record_link_path}     = $link->{path};
169          $_->{_record_link_obj_path} = [ map { $get_objects->($_) } @$intermediate_links ];
170          $_->{_record_link_depth}    = $link->{depth};
171        }
172     }
173
174     return \@objects;
175   }
176 }
177
178 sub link_to_record {
179   my $self   = shift;
180   my $other  = shift;
181   my %params = @_;
182
183   croak "self has no id"  unless $self->id;
184   croak "other has no id" unless $other->id;
185
186   my @directions = ([ 'from', 'to' ]);
187   push @directions, [ 'to', 'from' ] if $params{bidirectional};
188   my @links;
189
190   foreach my $direction (@directions) {
191     my %data = ( $direction->[0] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($self)),
192                  $direction->[0] . "_id"    => $self->id,
193                  $direction->[1] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($other)),
194                  $direction->[1] . "_id"    => $other->id,
195                );
196
197     my $link = SL::DB::Manager::RecordLink->find_by(and => [ %data ]);
198     push @links, $link ? $link : SL::DB::RecordLink->new(%data)->save;
199   }
200
201   return wantarray ? @links : $links[0];
202 }
203
204 sub sort_linked_records {
205   my ($self_or_class, $sort_by, $sort_dir, @records) = @_;
206
207   @records  = @{ $records[0] } if (1 == scalar(@records)) && (ref($records[0]) eq 'ARRAY');
208   $sort_dir = $sort_dir * 1 ? 1 : -1;
209
210   my %numbers = ( 'SL::DB::SalesProcess'    => sub { $_[0]->id },
211                   'SL::DB::Order'           => sub { $_[0]->quotation ? $_[0]->quonumber : $_[0]->ordnumber },
212                   'SL::DB::DeliveryOrder'   => sub { $_[0]->donumber },
213                   'SL::DB::Invoice'         => sub { $_[0]->invnumber },
214                   'SL::DB::PurchaseInvoice' => sub { $_[0]->invnumber },
215                   'SL::DB::RequirementSpec' => sub { $_[0]->id },
216                   'SL::DB::Letter'          => sub { $_[0]->letternumber },
217                   UNKNOWN                   => '9999999999999999',
218                 );
219   my $number_xtor = sub {
220     my $number = $numbers{ ref($_[0]) };
221     $number    = $number->($_[0]) if ref($number) eq 'CODE';
222     return $number || $numbers{UNKNOWN};
223   };
224   my $number_comparator = sub {
225     my $number_a = $number_xtor->($a);
226     my $number_b = $number_xtor->($b);
227
228     ncmp($number_a, $number_b) * $sort_dir;
229   };
230
231   my %scores;
232   %scores = ( 'SL::DB::SalesProcess'    =>  10,
233               'SL::DB::RequirementSpec' =>  15,
234               'SL::DB::Order'           =>  sub { $scores{ $_[0]->type } },
235               sales_quotation           =>  20,
236               sales_order               =>  30,
237               sales_delivery_order      =>  40,
238               'SL::DB::DeliveryOrder'   =>  sub { $scores{ $_[0]->type } },
239               'SL::DB::Invoice'         =>  50,
240               request_quotation         => 120,
241               purchase_order            => 130,
242               purchase_delivery_order   => 140,
243               'SL::DB::PurchaseInvoice' => 150,
244               'SL::DB::PurchaseInvoice' => 150,
245               'SL::DB::Letter'          => 200,
246               UNKNOWN                   => 999,
247             );
248   my $score_xtor = sub {
249     my $score = $scores{ ref($_[0]) };
250     $score    = $score->($_[0]) if ref($score) eq 'CODE';
251     return $score || $scores{UNKNOWN};
252   };
253   my $type_comparator = sub {
254     my $score_a = $score_xtor->($a);
255     my $score_b = $score_xtor->($b);
256
257     $score_a == $score_b ? $number_comparator->() : ($score_a <=> $score_b) * $sort_dir;
258   };
259
260   my $today     = DateTime->today_local;
261   my $date_xtor = sub {
262       $_[0]->can('transdate_as_date') ? $_[0]->transdate
263     : $_[0]->can('itime_as_date')     ? $_[0]->itime->clone->truncate(to => 'day')
264     :                                   $today;
265   };
266   my $date_comparator = sub {
267     my $date_a = $date_xtor->($a);
268     my $date_b = $date_xtor->($b);
269
270     ($date_a <=> $date_b) * $sort_dir;
271   };
272
273   my $comparator = $sort_by eq 'number' ? $number_comparator
274                  : $sort_by eq 'date'   ? $date_comparator
275                  :                        $type_comparator;
276
277   return [ sort($comparator @records) ];
278 }
279
280 sub filter_linked_records {
281   my ($self_or_class, $filter, @records) = @_;
282
283   if ($filter eq 'accessible') {
284     my $employee = SL::DB::Manager::Employee->current;
285     @records     = grep { !$_->can('may_be_accessed') || $_->may_be_accessed($employee) } @records;
286   } else {
287     croak "Unsupported filter parameter '${filter}'";
288   }
289
290   return \@records;
291 }
292
293 1;
294
295 __END__
296
297 =encoding utf8
298
299 =head1 NAME
300
301 SL::DB::Helper::LinkedRecords - Mixin for retrieving linked records via the table C<record_links>
302
303 SYNOPSIS
304
305   # In SL::DB::<Object>
306   use SL::DB::Helper::LinkedRecords;
307
308   # later in consumer code
309   # retrieve all links in both directions
310   my @linked_objects = $order->linked_records;
311
312   # only links to Invoices
313   my @linked_objects = $order->linked_records(
314     to        => 'Invoice',
315   );
316
317   # more than one target
318   my @linked_objects = $order->linked_records(
319     to        => [ 'Invoice', 'Order' ],
320   );
321
322   # more than one direction
323   my @linked_objects = $order->linked_records(
324     both      => 'Invoice',
325   );
326
327   # more than one direction and different targets
328   my @linked_objects = $order->linked_records(
329     to        => 'Invoice',
330     from      => 'Order',
331   );
332
333   # via over known classes
334   my @linked_objects = $order->linked_records(
335     to        => 'Invoice',
336     via       => 'DeliveryOrder',
337   );
338   my @linked_objects = $order->linked_records(
339     to        => 'Invoice',
340     via       => [ 'Order', 'DeliveryOrder' ],
341   );
342
343   # recursive
344   my @linked_objects = $order->linked_records(
345     recursive => 1,
346   );
347
348
349   # limit direction when further params contain additional keys
350   my %params = (to => 'Invoice', from => 'Order');
351   my @linked_objects = $order->linked_records(
352     direction => 'to',
353     %params,
354   );
355
356   # add a new link
357   $order->link_to_record($invoice);
358   $order->link_to_record($purchase_order, bidirectional => 1);
359
360
361 =head1 FUNCTIONS
362
363 =over 4
364
365 =item C<linked_records %params>
366
367 Retrieves records linked from or to C<$self> via the table C<record_links>.
368
369 The optional parameter C<direction> (either C<from>, C<to> or C<both>)
370 determines whether the function retrieves records that link to C<$self> (for
371 C<direction> = C<to>) or that are linked from C<$self> (for C<direction> =
372 C<from>). For C<direction = both> all records linked from or to C<$self> are
373 returned.
374
375 The optional parameter C<from> or C<to> (same as C<direction>) contains the
376 package names of Rose models for table limitation (the prefix C<SL::DB::> is
377 optional). It can be a single model name as a single scalar or multiple model
378 names in an array reference in which case all links matching any of the model
379 names will be returned.
380
381 If no parameter C<direction> is given, but any of C<to>, C<from> or C<both>,
382 then C<direction> is inferred accordingly. If neither are given, C<direction> is
383 set to C<both>.
384
385 The optional parameter C<via> can be used to retrieve all documents that may
386 have intermediate documents inbetween. It is an array reference of Rose package
387 names for the models that may be intermediate link targets. One example is
388 retrieving all invoices for a given quotation no matter whether or not orders
389 and delivery orders have been created. If C<via> is given then C<from> or C<to>
390 (depending on C<direction>) must be given as well, and it must then not be an
391 array reference.
392
393 Examples:
394
395 If you only need invoices created directly from an order C<$order> (no
396 delivery orders in between) then the call could look like this:
397
398   my $invoices = $order->linked_records(
399     direction => 'to',
400     to        => 'Invoice',
401   );
402
403 Retrieving all invoices from a quotation no matter whether or not
404 orders or delivery orders were created:
405
406   my $invoices = $quotation->linked_records(
407     direction => 'to',
408     to        => 'Invoice',
409     via       => [ 'Order', 'DeliveryOrder' ],
410   );
411
412 The optional parameter C<query> can be used to limit the records
413 returned. The following call limits the earlier example to invoices
414 created today:
415
416   my $invoices = $order->linked_records(
417     direction => 'to',
418     to        => 'Invoice',
419     query     => [ transdate => DateTime->today_local ],
420   );
421
422 In case you don't know or care which or how many objects are visited the flag
423 C<recursive> can be used. It searches all reachable objects in the given direction:
424
425   my $records = $order->linked_records(
426     direction => 'to',
427     recursive => 1,
428   );
429
430 Only link chains of the same type will be considered. So even with direction
431 both, this
432
433   order 1 ---> invoice <--- order 2
434
435 started from order 1 will only find invoice. If an object is found both in each
436 direction, only one copy will be returned. The recursion is cycle protected,
437 and will not recurse infinitely. Cycles are defined by the same link being
438 visited twice, so this
439
440
441   order 1 ---> order 2 <--> delivery order
442                  |
443                  `--------> invoice
444
445 will find the path o1 -> o2 -> do -> o2 -> i without considering it a cycle.
446
447 The optional extra flag C<save_path> will give you extra information saved in
448 the returned objects:
449
450   my $records = $order->linked_records(
451     direction => 'to',
452     recursive => 1,
453     save_path => 1,
454   );
455
456 Every record will have two fields set:
457
458 =over 2
459
460 =item C<_record_link_path>
461
462 An array with the ids of the visited links. The shortest paths will be
463 preferred, so in the previous example this would contain the ids of o1-o2 and
464 o2-i.
465
466 =item C<_record_link_depth>
467
468 Recursion depth when this object was found. Equal to the number of ids in
469 C<_record_link_path>
470
471 =back
472
473
474 The optional parameters C<$params{sort_by}> and C<$params{sort_dir}>
475 can be used in order to sort the result. If C<$params{sort_by}> is
476 trueish then the result is sorted by calling L</sort_linked_records>.
477
478 The optional parameter C<$params{filter}> controls whether or not the
479 result is filtered. Supported values are:
480
481 =over 2
482
483 =item C<accessible>
484
485 Removes all objects for which the function C<may_be_accessed> from the
486 mixin L<SL::DB::Helper::MayBeAccessed> exists and returns falsish for
487 the current employee.
488
489 =back
490
491 Returns an array reference. Each element returned is a Rose::DB
492 instance. Additionally several elements in the element returned are
493 set to special values:
494
495 =over 2
496
497 =item C<_record_link_direction>
498
499 Either C<from> or C<to> indicating the direction. C<from> means that
500 this object is the source in the link.
501
502 =item C<_record_link>
503
504 The actual database link object (an instance of L<SL::DB::RecordLink>).
505
506 =back
507
508 =item C<link_to_record $record, %params>
509
510 Will create an entry in the table C<record_links> with the C<from>
511 side being C<$self> and the C<to> side being C<$record>. Will only
512 insert a new entry if such a link does not already exist.
513
514 If C<$params{bidirectional}> is trueish then another link will be
515 created with the roles of C<from> and C<to> reversed. This link will
516 also only be created if it doesn't exist already.
517
518 In scalar context returns either the existing link or the newly
519 created one as an instance of C<SL::DB::RecordLink>. In array context
520 it returns an array of links (one entry if C<$params{bidirectional}>
521 is falsish and two entries if it is trueish).
522
523 =item C<sort_linked_records $sort_by, $sort_dir, @records>
524
525 Sorts linked records by C<$sort_by> in the direction given by
526 C<$sort_dir> (trueish = ascending, falsish = descending). C<@records>
527 can be either a single array reference or or normal array.
528
529 C<$sort_by> can be one of the following strings:
530
531 =over 2
532
533 =item * C<type>
534
535 Sort by type first and by record number second. The type order
536 reflects the order in which records are usually processed by the
537 employees: sales processes, sales quotations, sales orders, sales
538 delivery orders, invoices; requests for quotation, purchase orders,
539 purchase delivery orders, purchase invoices.
540
541 =item * C<number>
542
543 Sort by the record's running number.
544
545 =item * C<date>
546
547 Sort by the transdate of the record was created or applies to.
548
549 Note: If the latter has a default setting it will always mask the creation time.
550
551 =back
552
553 Returns an array reference.
554
555 Can only be called both as a class function since it is not exported.
556
557 =back
558
559 =head1 EXPORTS
560
561 This mixin exports the functions L</linked_records> and
562 L</link_to_record>.
563
564 =head1 BUGS
565
566 Nothing here yet.
567
568 =head1 TODO
569
570  * C<recursive> should take a query param depth and cut off there
571  * C<recursive> uses partial distinct which is known to be not terribly fast on
572    a million entry table. replace with a better statement if this ever becomes
573    an issue.
574
575 =head1 AUTHOR
576
577 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
578 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
579
580 =cut