1 package SL::DB::Helper::LinkedRecords;
6 our @ISA = qw(Exporter);
7 our @EXPORT = qw(linked_records link_to_record sales_order_centric_linked_records);
10 use List::MoreUtils qw(any none);
11 use List::UtilsBy qw(uniq_by);
15 use SL::DB::Helper::Mappings;
16 use SL::DB::RecordLink;
19 my ($self, %params) = @_;
21 my %sort_spec = ( by => delete($params{sort_by}),
22 dir => delete($params{sort_dir}) );
23 my $filter = delete $params{filter};
25 my $records = _linked_records_implementation($self, %params);
26 $records = filter_linked_records($self, $filter, @{ $records }) if $filter;
27 $records = sort_linked_records($self, $sort_spec{by}, $sort_spec{dir}, @{ $records }) if $sort_spec{by};
32 sub _linked_records_implementation {
36 my $wanted = $params{direction};
39 if ($params{to} && $params{from}) {
41 } elsif ($params{to}) {
43 } elsif ($params{from}) {
50 if ($wanted eq 'both') {
51 my $both = delete($params{both});
52 my %from_to = ( from => delete($params{from}) || $both,
53 to => delete($params{to}) || $both);
55 if ($params{batch} && $params{by_id}) {
58 _linked_records_implementation($self, %params, direction => 'from', from => $from_to{from}),
59 _linked_records_implementation($self, %params, direction => 'to', to => $from_to{to} ),
62 for my $by_id (@links) {
64 $results{$_} = defined $results{$_}
65 ? [ uniq_by { $_->id } @{ $results{$_} }, @{ $by_id->{$_} } ]
72 my @records = (@{ _linked_records_implementation($self, %params, direction => 'from', from => $from_to{from}) },
73 @{ _linked_records_implementation($self, %params, direction => 'to', to => $from_to{to} ) });
75 my %record_map = map { ( ref($_) . $_->id => $_ ) } @records;
77 return [ values %record_map ];
82 croak("Cannot use 'via' without '${wanted}_table'") if !$params{$wanted};
83 croak("Cannot use 'via' with '${wanted}_table' being an array") if ref $params{$wanted};
86 my $myself = $wanted eq 'from' ? 'to' : $wanted eq 'to' ? 'from' : croak("Invalid parameter `direction'");
87 my $my_table = SL::DB::Helper::Mappings::get_table_for_package(ref($self));
89 my $sub_wanted_table = "${wanted}_table";
90 my $sub_wanted_id = "${wanted}_id";
91 my $sub_myself_id = "${myself}_id";
93 my ($wanted_classes, $wanted_tables);
94 if ($params{$wanted}) {
95 $wanted_classes = ref($params{$wanted}) eq 'ARRAY' ? $params{$wanted} : [ $params{$wanted} ];
96 $wanted_tables = [ map { SL::DB::Helper::Mappings::get_table_for_package($_) || croak("Invalid parameter `${wanted}'") } @{ $wanted_classes } ];
99 my @get_objects_query = ref($params{query}) eq 'ARRAY' ? @{ $params{query} } : ();
100 my $get_objects = sub {
102 return [] unless @$links;
105 push @{ $classes{ $_->$sub_wanted_table } //= [] }, $_->$sub_wanted_id for @$links;
108 for (keys %classes) {
109 my $manager_class = SL::DB::Helper::Mappings::get_manager_package_for_table($_);
110 my $object_class = SL::DB::Helper::Mappings::get_package_for_table($_);
111 eval "require " . $object_class . "; 1;";
113 push @objs, @{ $manager_class->get_all(
114 query => [ id => $classes{$_}, @get_objects_query ],
115 (with_objects => $params{with_objects}) x !!$params{with_objects},
120 my %objs_by_id = map { $_->id => $_ } @objs;
123 if ('ARRAY' eq ref $objs_by_id{$_->$sub_wanted_id}->{_record_link}) {
124 push @{ $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} }, $wanted;
125 push @{ $objs_by_id{$_->$sub_wanted_id}->{_record_link } }, $_;
126 } elsif ($objs_by_id{$_->$sub_wanted_id}->{_record_link}) {
127 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} = [
128 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction},
131 $objs_by_id{$_->$sub_wanted_id}->{_record_link} = [
132 $objs_by_id{$_->$sub_wanted_id}->{_record_link},
136 $objs_by_id{$_->$sub_wanted_id}->{_record_link_direction} = $wanted;
137 $objs_by_id{$_->$sub_wanted_id}->{_record_link} = $_;
144 # If no 'via' is given then use a simple(r) method for querying the wanted objects.
145 if (!$params{via} && !$params{recursive}) {
146 my @query = ( "${myself}_table" => $my_table,
147 "${myself}_id" => $params{batch} ? $params{batch} : $self->id );
148 push @query, ( "${wanted}_table" => $wanted_tables ) if $wanted_tables;
150 my $links = SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]);
151 my $objs = $get_objects->($links);
153 if ($params{batch} && $params{by_id}) {
159 $_->{_record_link}->$sub_myself_id == $id
162 } @{ $params{batch} }
169 # More complex handling for the 'via' case.
171 die 'batch mode is not supported with via' if $params{batch};
173 my @sources = ( $self );
174 my @targets = map { SL::DB::Helper::Mappings::get_table_for_package($_) } @{ ref($params{via}) ? $params{via} : [ $params{via} ] };
175 push @targets, @{ $wanted_tables } if $wanted_tables;
177 my %seen = map { ($_->meta->table . $_->id => 1) } @sources;
180 my @new_sources = @sources;
181 foreach my $src (@sources) {
182 my @query = ( "${myself}_table" => $src->meta->table,
183 "${myself}_id" => $src->id,
184 "${wanted}_table" => \@targets );
187 grep { !$seen{$_->$sub_wanted_table . $_->$sub_wanted_id} }
188 @{ SL::DB::Manager::RecordLink->get_all(query => [ and => \@query ]) }
192 @sources = @new_sources;
193 %seen = map { ($_->meta->table . $_->id => 1) } @sources;
197 my %wanted_tables_map = map { ($_ => 1) } @{ $wanted_tables };
198 return [ grep { $wanted_tables_map{$_->meta->table} } @sources ];
201 # And lastly recursive mode
202 if ($params{recursive}) {
203 my ($id_token, @ids);
204 if ($params{batch}) {
205 $id_token = sprintf 'IN (%s)', join ', ', ('?') x @{ $params{batch} };
206 @ids = @{ $params{batch} };
212 # don't use rose retrieval here. too slow.
213 # instead use recursive sql to get all the linked record_links entries and retrieve the objects from there
215 WITH RECURSIVE record_links_rec_${wanted}(id, from_table, from_id, to_table, to_id, depth, path, cycle) AS (
216 SELECT id, from_table, from_id, to_table, to_id,
219 WHERE ${myself}_id $id_token and ${myself}_table = ?
221 SELECT rl.id, rl.from_table, rl.from_id, rl.to_table, rl.to_id,
222 rlr.depth + 1, path || rl.id, rl.id = ANY(path)
223 FROM record_links rl, record_links_rec_${wanted} rlr
224 WHERE rlr.${wanted}_id = rl.${myself}_id AND rlr.${wanted}_table = rl.${myself}_table AND NOT cycle
226 SELECT DISTINCT ON (${wanted}_table, ${wanted}_id)
227 id, from_table, from_id, to_table, to_id, path, depth FROM record_links_rec_${wanted}
229 ORDER BY ${wanted}_table, ${wanted}_id, depth ASC;
231 my $links = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, @ids, $self->meta->table);
234 return $params{by_id} ? {} : [];
237 my $link_objs = SL::DB::Manager::RecordLink->get_all(query => [ id => [ map { $_->{id} } @$links ] ]);
238 my $objects = $get_objects->($link_objs);
240 my %links_by_id = map { $_->{id} => $_ } @$links;
242 if ($params{save_path}) {
244 for my $record_link ('ARRAY' eq ref $_->{_record_link} ? @{ $_->{_record_link} } : $_->{_record_link}) {
245 my $link = $links_by_id{$record_link->id};
246 my $intermediate_links = SL::DB::Manager::RecordLink->get_all(query => [ id => $link->{path} ]);
247 $_->{_record_link_path} = $link->{path};
248 $_->{_record_link_obj_path} = $get_objects->($intermediate_links);
249 $_->{_record_link_depth} = $link->{depth};
254 if ($params{batch} && $params{by_id}) {
255 my %link_obj_by_id = map { $_->id => $_ } @$link_objs;
263 $links_by_id{$_->id}->{path}->[0]
264 }->$sub_myself_id == $id
265 } 'ARRAY' eq $_->{_record_link} ? @{ $_->{_record_link} } : $_->{_record_link}
268 } @{ $params{batch} }
281 croak "self has no id" unless $self->id;
282 croak "other has no id" unless $other->id;
284 my @directions = ([ 'from', 'to' ]);
285 push @directions, [ 'to', 'from' ] if $params{bidirectional};
288 foreach my $direction (@directions) {
289 my %data = ( $direction->[0] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($self)),
290 $direction->[0] . "_id" => $self->id,
291 $direction->[1] . "_table" => SL::DB::Helper::Mappings::get_table_for_package(ref($other)),
292 $direction->[1] . "_id" => $other->id,
295 my $link = SL::DB::Manager::RecordLink->find_by(and => [ %data ]);
296 push @links, $link ? $link : SL::DB::RecordLink->new(%data)->save;
299 return wantarray ? @links : $links[0];
302 sub sort_linked_records {
303 my ($self_or_class, $sort_by, $sort_dir, @records) = @_;
305 @records = @{ $records[0] } if (1 == scalar(@records)) && (ref($records[0]) eq 'ARRAY');
306 $sort_dir = $sort_dir * 1 ? 1 : -1;
308 my %numbers = ( 'SL::DB::SalesProcess' => sub { $_[0]->id },
309 'SL::DB::Order' => sub { $_[0]->record_number },
310 'SL::DB::DeliveryOrder' => sub { $_[0]->donumber },
311 'SL::DB::Invoice' => sub { $_[0]->invnumber },
312 'SL::DB::PurchaseInvoice' => sub { $_[0]->invnumber },
313 'SL::DB::RequirementSpec' => sub { $_[0]->id },
314 'SL::DB::Letter' => sub { $_[0]->letternumber },
315 'SL::DB::ShopOrder' => sub { $_[0]->shop_ordernumber },
316 'SL::DB::EmailJournal' => sub { $_[0]->id },
317 'SL::DB::Dunning' => sub { $_[0]->dunning_id },
318 'SL::DB::GLTransaction' => sub { $_[0]->reference },
319 UNKNOWN => '9999999999999999',
321 my $number_xtor = sub {
322 my $number = $numbers{ ref($_[0]) };
323 $number = $number->($_[0]) if ref($number) eq 'CODE';
324 return $number || $numbers{UNKNOWN};
326 my $number_comparator = sub {
327 my $number_a = $number_xtor->($a);
328 my $number_b = $number_xtor->($b);
330 ncmp($number_a, $number_b) * $sort_dir;
334 %scores = ( 'SL::DB::SalesProcess' => 10,
335 'SL::DB::RequirementSpec' => 15,
336 'SL::DB::Order' => sub { $scores{ $_[0]->type } },
337 sales_quotation => 20,
338 sales_order_intake => 25,
340 sales_delivery_order => 40,
341 'SL::DB::DeliveryOrder' => sub { $scores{ $_[0]->type } },
342 'SL::DB::Invoice' => 50,
343 request_quotation => 120,
344 purchase_quotation_intake => 125,
345 purchase_order => 130,
346 purchase_order_confirmation => 135,
347 purchase_delivery_order => 140,
348 'SL::DB::PurchaseInvoice' => 150,
349 'SL::DB::GLTransaction' => 170,
350 'SL::DB::Letter' => 200,
351 'SL::DB::ShopOrder' => 250,
352 'SL::DB::EmailJournal' => 300,
353 'SL::DB::Dunning' => 350,
356 my $score_xtor = sub {
357 my $score = $scores{ ref($_[0]) };
358 $score = $score->($_[0]) if ref($score) eq 'CODE';
359 return $score || $scores{UNKNOWN};
361 my $type_comparator = sub {
362 my $score_a = $score_xtor->($a);
363 my $score_b = $score_xtor->($b);
365 $score_a == $score_b ? $number_comparator->() : ($score_a <=> $score_b) * $sort_dir;
368 my $today = DateTime->today_local;
369 my $date_xtor = sub {
370 $_[0]->can('transdate_as_date') ? $_[0]->transdate
371 : $_[0]->can('itime_as_date') ? $_[0]->itime->clone->truncate(to => 'day')
374 my $date_comparator = sub {
375 my $date_a = $date_xtor->($a);
376 my $date_b = $date_xtor->($b);
378 ($date_a <=> $date_b) * $sort_dir;
381 my $comparator = $sort_by eq 'number' ? $number_comparator
382 : $sort_by eq 'date' ? $date_comparator
385 return [ sort($comparator @records) ];
388 sub filter_linked_records {
389 my ($self_or_class, $filter, @records) = @_;
391 if ($filter eq 'accessible') {
392 my $employee = SL::DB::Manager::Employee->current;
393 @records = grep { !$_->can('may_be_accessed') || $_->may_be_accessed($employee) } @records;
395 croak "Unsupported filter parameter '${filter}'";
401 sub sales_order_centric_linked_records {
402 my ($self, %params) = @_;
404 my $with_sales_quotations = $params{with_sales_quotations};
405 my $with_myself = $params{with_myself};
407 my $all_linked_records = $self->linked_records(direction => 'both', recursive => 1, save_path => 1);
409 if (!$with_sales_quotations) {
410 $all_linked_records = [ grep { !('SL::DB::Order' eq ref $_ && $_->is_sales && $_->quotation) } @$all_linked_records ];
414 $self->{_record_link_to_myself} = 1;
415 push @$all_linked_records, $self;
418 my $filtered_orders = [ grep {
419 'SL::DB::Order' eq ref $_ &&
420 ($_->is_type('sales_order') || $_->is_type('sales_order_intake')) &&
421 $_->{_record_link_direction} eq 'from'
422 } @$all_linked_records ];
424 # no orders no call to linked_records via batch mode
425 # but instead return default list
426 return $all_linked_records unless scalar @$filtered_orders;
428 # we have an order, therefore get the tree view from the top (order)
429 my $id_ref = [ map { $_->id } @$filtered_orders ];
431 my $linked_records = SL::DB::Order->new->linked_records(direction => 'to', recursive => 1, batch => $id_ref);
433 # Remove entries that are already in all_linked_records.
434 $linked_records = [ grep { my $id = $_->id; none { $_->id == $id } @$all_linked_records } @$linked_records ];
436 # Remove quotations if requested.
437 if (!$with_sales_quotations) {
438 $linked_records = [ grep { !('SL::DB::Order' eq ref $_ && $_->is_sales && $_->quotation) } @$linked_records ];
441 # Mark or remove myself.
443 $_->{_record_link_to_myself} = 1 for grep { $_->id == $self->id } @$linked_records;
445 $linked_records = [ grep { $_->id != $self->id } @$linked_records ];
448 # All remaining links found via order are two more steps away from myself.
449 $_->{_record_link_depth} += 2 for @{ $linked_records };
451 push @{ $linked_records }, @$all_linked_records;
453 return $linked_records;
464 SL::DB::Helper::LinkedRecords - Mixin for retrieving linked records via the table C<record_links>
468 # In SL::DB::<Object>
469 use SL::DB::Helper::LinkedRecords;
471 # later in consumer code
472 # retrieve all links in both directions
473 my @linked_objects = $order->linked_records;
475 # only links to Invoices
476 my @linked_objects = $order->linked_records(
480 # more than one target
481 my @linked_objects = $order->linked_records(
482 to => [ 'Invoice', 'Order' ],
485 # more than one direction
486 my @linked_objects = $order->linked_records(
490 # more than one direction and different targets
491 my @linked_objects = $order->linked_records(
496 # via over known classes
497 my @linked_objects = $order->linked_records(
499 via => 'DeliveryOrder',
501 my @linked_objects = $order->linked_records(
503 via => [ 'Order', 'DeliveryOrder' ],
507 my @linked_objects = $order->linked_records(
512 # limit direction when further params contain additional keys
513 my %params = (to => 'Invoice', from => 'Order');
514 my @linked_objects = $order->linked_records(
519 # get order centric linked records
520 $invoice->sales_order_centric_linked_records(
522 with_sales_quotations => 1
526 $order->link_to_record($invoice);
527 $order->link_to_record($purchase_order, bidirectional => 1);
534 =item C<linked_records %params>
536 Retrieves records linked from or to C<$self> via the table C<record_links>.
538 The optional parameter C<direction> (either C<from>, C<to> or C<both>)
539 determines whether the function retrieves records that link to C<$self> (for
540 C<direction> = C<to>) or that are linked from C<$self> (for C<direction> =
541 C<from>). For C<direction = both> all records linked from or to C<$self> are
544 The optional parameter C<from> or C<to> (same as C<direction>) contains the
545 package names of Rose models for table limitation (the prefix C<SL::DB::> is
546 optional). It can be a single model name as a single scalar or multiple model
547 names in an array reference in which case all links matching any of the model
548 names will be returned.
550 If no parameter C<direction> is given, but any of C<to>, C<from> or C<both>,
551 then C<direction> is inferred accordingly. If neither are given, C<direction> is
554 The optional parameter C<via> can be used to retrieve all documents that may
555 have intermediate documents inbetween. It is an array reference of Rose package
556 names for the models that may be intermediate link targets. One example is
557 retrieving all invoices for a given quotation no matter whether or not orders
558 and delivery orders have been created. If C<via> is given then C<from> or C<to>
559 (depending on C<direction>) must be given as well, and it must then not be an
564 If you only need invoices created directly from an order C<$order> (no
565 delivery orders in between) then the call could look like this:
567 my $invoices = $order->linked_records(
572 Retrieving all invoices from a quotation no matter whether or not
573 orders or delivery orders were created:
575 my $invoices = $quotation->linked_records(
578 via => [ 'Order', 'DeliveryOrder' ],
581 The optional parameter C<query> can be used to limit the records
582 returned. The following call limits the earlier example to invoices
585 my $invoices = $order->linked_records(
588 query => [ transdate => DateTime->today_local ],
591 In case you don't know or care which or how many objects are visited the flag
592 C<recursive> can be used. It searches all reachable objects in the given direction:
594 my $records = $order->linked_records(
599 Only link chains of the same type will be considered. So even with direction
602 order 1 ---> invoice <--- order 2
604 started from order 1 will only find invoice. If an object is found both in each
605 direction, only one copy will be returned. The recursion is cycle protected,
606 and will not recurse infinitely. Cycles are defined by the same link being
607 visited twice, so this
610 order 1 ---> order 2 <--> delivery order
614 will find the path o1 -> o2 -> do -> o2 -> i without considering it a cycle.
616 The optional extra flag C<save_path> will give you extra information saved in
617 the returned objects:
619 my $records = $order->linked_records(
625 Every record will have two fields set:
629 =item C<_record_link_path>
631 An array with the ids of the visited links. The shortest paths will be
632 preferred, so in the previous example this would contain the ids of o1-o2 and
635 =item C<_record_link_depth>
637 Recursion depth when this object was found. Equal to the number of ids in
642 Since record_links is comparatively expensive to call, you will want to cache
643 the results for multiple objects if you know in advance you'll need them.
645 You can pass the optional argument C<batch> with an array ref of ids which will
646 be used instead of the id of the invocant. You still need to call it as a
647 method on a valid object, because table information is inferred from there.
649 C<batch> mode will currenty not work with C<via>.
651 The optional flag C<by_id> will return the objects sorted into a hash instead
652 of a plain array. Calling C<<recursive => 1, batch => [1,2], by_id => 1>> on
655 order 1 --> delivery order 1 --> invoice 1
656 order 2 --> delivery order 2 --> invoice 2
660 { 1 => [ delivery order 1, invoice 1 ],
661 2 => [ delivery order 2, invoice 1 ], }
663 you may then cache these as you see fit.
666 The optional parameters C<$params{sort_by}> and C<$params{sort_dir}>
667 can be used in order to sort the result. If C<$params{sort_by}> is
668 trueish then the result is sorted by calling L</sort_linked_records>.
670 The optional parameter C<$params{filter}> controls whether or not the
671 result is filtered. Supported values are:
677 Removes all objects for which the function C<may_be_accessed> from the
678 mixin L<SL::DB::Helper::MayBeAccessed> exists and returns falsish for
679 the current employee.
683 Returns an array reference. Each element returned is a Rose::DB
684 instance. Additionally several elements in the element returned are
685 set to special values:
689 =item C<_record_link_direction>
691 Either C<from> or C<to> indicating the direction. C<from> means that
692 this object is the source in the link.
694 =item C<_record_link>
696 The actual database link object (an instance of L<SL::DB::RecordLink>).
700 =item C<link_to_record $record, %params>
702 Will create an entry in the table C<record_links> with the C<from>
703 side being C<$self> and the C<to> side being C<$record>. Will only
704 insert a new entry if such a link does not already exist.
706 If C<$params{bidirectional}> is trueish then another link will be
707 created with the roles of C<from> and C<to> reversed. This link will
708 also only be created if it doesn't exist already.
710 In scalar context returns either the existing link or the newly
711 created one as an instance of C<SL::DB::RecordLink>. In array context
712 it returns an array of links (one entry if C<$params{bidirectional}>
713 is falsish and two entries if it is trueish).
715 =item C<sort_linked_records $sort_by, $sort_dir, @records>
717 Sorts linked records by C<$sort_by> in the direction given by
718 C<$sort_dir> (trueish = ascending, falsish = descending). C<@records>
719 can be either a single array reference or or normal array.
721 C<$sort_by> can be one of the following strings:
727 Sort by type first and by record number second. The type order
728 reflects the order in which records are usually processed by the
729 employees: sales processes, sales quotations, sales orders, sales
730 delivery orders, invoices; requests for quotation, purchase orders,
731 purchase delivery orders, purchase invoices.
735 Sort by the record's running number.
739 Sort by the transdate of the record was created or applies to.
741 Note: If the latter has a default setting it will always mask the creation time.
745 Returns an array reference.
747 Can only be called as a class function since it is not exported.
749 =item C<sales_order_centric_linked_records %params>
751 Get linked records from the view of a reachable sales order in the path
752 prior to this record. If no sales order is found, recursive linked records
753 for both directions are returned.
757 =item * C<with_sales_quotations>
759 Since the view from a sales order is requested, normally no sales quotation
760 prior to the sales order will be returned. If this parameter is truish, then
761 a sales quotation would be included.
762 This parameter is optional and defaults to false.
764 =item * C<with_myself>
766 The records returned by L</linked_records> do not include the requesting record
767 itself. However, if you want to display the same linked records for every
768 record in the same workflow, it can be usefull to include the record itself.
769 C<_record_link_to_myself> will be set to a truish value in that record in
771 This parameter is optional and defaults to false.
779 This mixin exports the functions L</linked_records>,
780 L</sales_order_centric_linked_records> and
789 * C<recursive> should take a query param depth and cut off there
790 * C<recursive> uses partial distinct which is known to be not terribly fast on
791 a million entry table. replace with a better statement if this ever becomes
796 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
797 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>