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