38a450eb25d170d8a389ca0a83543783f815dc42
[kivitendo-erp.git] / SL / Helper / ShippedQty.pm
1 package SL::Helper::ShippedQty;
2
3 use strict;
4 use parent qw(Rose::Object);
5
6 use SL::AM;
7 use Scalar::Util qw(blessed);
8 use SL::DBUtils qw(selectall_hashref_query selectall_as_map);
9 use List::Util qw(min);
10 use List::MoreUtils qw(any all);
11 use List::UtilsBy qw(partition_by);
12
13 use Rose::Object::MakeMethods::Generic (
14   'scalar'                => [ qw(objects objects_or_ids shipped_qty ) ],
15   'scalar --get_set_init' => [ qw(oe_ids dbh require_stock_out fill_up item_identity_fields oi2oe oi_qty delivered) ],
16 );
17
18 my $no_stock_item_links_query = <<'';
19   SELECT oi.trans_id, oi.id AS oi_id, oi.qty AS oi_qty, oi.unit AS oi_unit, doi.qty AS doi_qty, doi.unit AS doi_unit
20   FROM record_links rl
21   INNER JOIN orderitems oi            ON oi.id = rl.from_id AND rl.from_table = 'orderitems'
22   INNER JOIN delivery_order_items doi ON doi.id = rl.to_id AND rl.to_table = 'delivery_order_items'
23   WHERE oi.trans_id IN (%s)
24   ORDER BY oi.trans_id, oi.position
25
26 # oi not item linked. takes about 250ms for 100k hits
27 my $fill_up_oi_query = <<'';
28   SELECT oi.id, oi.trans_id, oi.position, oi.parts_id, oi.description, oi.reqdate, oi.serialnumber, oi.qty, oi.unit
29   FROM orderitems oi
30   WHERE oi.trans_id IN (%s)
31   ORDER BY oi.trans_id, oi.position
32
33 # doi linked by record, but not by items; 250ms for 100k hits
34 my $no_stock_fill_up_doi_query = <<'';
35   SELECT doi.id, doi.delivery_order_id, doi.position, doi.parts_id, doi.description, doi.reqdate, doi.serialnumber, doi.qty, doi.unit
36   FROM delivery_order_items doi
37   WHERE doi.delivery_order_id IN (
38     SELECT to_id
39     FROM record_links
40     WHERE from_id IN (%s)
41       AND from_table = 'oe'
42       AND to_table = 'delivery_orders'
43       AND to_id = doi.delivery_order_id)
44    AND NOT EXISTS (
45     SELECT NULL
46     FROM record_links
47     WHERE from_table = 'orderitems'
48       AND to_table = 'delivery_order_items'
49       AND to_id = doi.id)
50
51 my $stock_item_links_query = <<'';
52   SELECT oi.trans_id, oi.id AS oi_id, oi.qty AS oi_qty, oi.unit AS oi_unit, i.qty AS doi_qty, p.unit AS doi_unit
53   FROM record_links rl
54   INNER JOIN orderitems oi                   ON oi.id = rl.from_id AND rl.from_table = 'orderitems'
55   INNER JOIN delivery_order_items doi        ON doi.id = rl.to_id AND rl.to_table = 'delivery_order_items'
56   INNER JOIN delivery_order_items_stock dois ON dois.delivery_order_item_id = doi.id
57   INNER JOIN inventory i                     ON dois.id = i.delivery_order_items_stock_id
58   INNER JOIN parts p                         ON p.id = doi.parts_id
59   WHERE oi.trans_id IN (%s)
60   ORDER BY oi.trans_id, oi.position
61
62 my $stock_fill_up_doi_query = <<'';
63   SELECT doi.id, doi.delivery_order_id, doi.position, doi.parts_id, doi.description, doi.reqdate, doi.serialnumber, i.qty, i.unit
64   FROM delivery_order_items doi
65   INNER JOIN parts p                         ON p.id = doi.parts_id
66   INNER JOIN delivery_order_items_stock dois ON dois.delivery_order_item_id = doi.id
67   INNER JOIN inventory i                     ON dois.id = i.delivery_order_items_stock_id
68   WHERE doi.delivery_order_id IN (
69     SELECT to_id
70     FROM record_links
71     WHERE from_id IN (%s)
72       AND from_table = 'oe'
73       AND to_table = 'delivery_orders'
74       AND to_id = doi.delivery_order_id)
75    AND NOT EXISTS (
76     SELECT NULL
77     FROM record_links
78     WHERE from_table = 'orderitems'
79       AND to_table = 'delivery_order_items'
80       AND to_id = doi.id)
81
82 my $oe_do_record_links = <<'';
83   SELECT from_id, to_id
84   FROM record_links
85   WHERE from_id IN (%s)
86     AND from_table = 'oe'
87     AND to_table = 'delivery_orders'
88
89 sub calculate {
90   my ($self, $data) = @_;
91
92   die 'Need exactly one argument, either id, object or arrayref of ids or objects.' unless 2 == @_;
93
94   return if !$data || ('ARRAY' eq ref $data && !@$data);
95
96   $self->normalize_input($data);
97
98   return unless @{ $self->oe_ids };
99
100   $self->calculate_item_links;
101   $self->calculate_fill_up if $self->fill_up;
102 }
103
104 sub calculate_item_links {
105   my ($self) = @_;
106
107   my @oe_ids = @{ $self->oe_ids };
108
109   my $item_links_query = $self->require_stock_out ? $stock_item_links_query : $no_stock_item_links_query;
110
111   my $query = sprintf $item_links_query, join (', ', ('?')x @oe_ids);
112
113   my $data = selectall_hashref_query($::form, $self->dbh, $query, @oe_ids);
114
115   for (@$data) {
116     $self->shipped_qty->{$_->{oi_id}} //= 0;
117     $self->shipped_qty->{$_->{oi_id}} += $_->{doi_qty} * AM->convert_unit($_->{doi_unit} => $_->{oi_unit});
118     $self->oi2oe->{$_->{oi_id}}        = $_->{trans_id};
119     $self->oi_qty->{$_->{oi_id}}       = $_->{oi_qty};
120   }
121 }
122
123 sub _intersect {
124   my ($a1, $a2) = @_;
125   my %seen;
126   grep { $seen{$_}++ } @$a1, @$a2;
127 }
128
129 sub calculate_fill_up {
130   my ($self) = @_;
131
132   my @oe_ids = @{ $self->oe_ids };
133
134   my $fill_up_doi_query = $self->require_stock_out ? $stock_fill_up_doi_query : $no_stock_fill_up_doi_query;
135
136   my $oi_query  = sprintf $fill_up_oi_query,   join (', ', ('?')x@oe_ids);
137   my $doi_query = sprintf $fill_up_doi_query,  join (', ', ('?')x@oe_ids);
138   my $rl_query  = sprintf $oe_do_record_links, join (', ', ('?')x@oe_ids);
139
140   my $oi  = selectall_hashref_query($::form, $self->dbh, $oi_query,  @oe_ids);
141
142   return unless @$oi;
143
144   my $doi = selectall_hashref_query($::form, $self->dbh, $doi_query, @oe_ids);
145   my $rl  = selectall_hashref_query($::form, $self->dbh, $rl_query,  @oe_ids);
146
147   my %oi_by_identity  = partition_by { $self->item_identity($_) } @$oi;
148   my %doi_by_id       = partition_by { $_->{delivery_order_id} } @$doi;
149   my %doi_by_trans_id;
150   push @{ $doi_by_trans_id{$_->{from_id}} //= [] }, @{ $doi_by_id{$_->{to_id}} }
151     for grep { exists $doi_by_id{$_->{to_id}} } @$rl;
152
153   my %doi_by_identity = partition_by { $self->item_identity($_) } @$doi;
154
155   for my $match (sort keys %oi_by_identity) {
156     next unless exists $doi_by_identity{$match};
157
158     my %oi_by_oe = partition_by { $_->{trans_id} } @{ $oi_by_identity{$match} };
159     for my $trans_id (sort { $a <=> $b } keys %oi_by_oe) {
160       next unless my @sorted_doi = _intersect($doi_by_identity{$match}, $doi_by_trans_id{$trans_id});
161
162       # sorting should be quite fast here, because there are usually only a handful of matches
163       next unless my @sorted_oi  = sort { $a->{position} <=> $b->{position} } @{ $oi_by_oe{$trans_id} };
164
165       # parallel walk through sorted oi/doi entries
166       my $oi_i = my $doi_i = 0;
167       my ($oi, $doi) = ($sorted_oi[$oi_i], $sorted_doi[$doi_i]);
168       while ($oi_i < @sorted_oi && $doi_i < @sorted_doi) {
169         $oi =  $sorted_oi[++$oi_i],   next if $oi->{qty} <= $self->shipped_qty->{$oi->{id}};
170         $doi = $sorted_doi[++$doi_i], next if 0 == $doi->{qty};
171
172         my $factor  = AM->convert_unit($doi->{unit} => $oi->{unit});
173         my $min_qty = min($oi->{qty} - $self->shipped_qty->{$oi->{id}}, $doi->{qty} * $factor);
174
175         # min_qty should never be 0 now. the first part triggers the first next,
176         # the second triggers the second next and factor must not be 0
177         # but it would lead to an infinite loop, so catch that.
178         die 'panic! invalid shipping quantity' unless $min_qty;
179
180         $self->shipped_qty->{$oi->{id}} += $min_qty;
181         $doi->{qty}                     -= $min_qty / $factor;  # TODO: find a way to avoid float rounding
182       }
183     }
184   }
185
186   $self->oi2oe->{$_->{id}}  = $_->{trans_id} for @$oi;
187   $self->oi_qty->{$_->{id}} = $_->{qty}      for @$oi;
188 }
189
190 sub write_to {
191   my ($self, $objects) = @_;
192
193   die 'expecting array of objects' unless 'ARRAY' eq ref $objects;
194
195   my $shipped_qty = $self->shipped_qty;
196
197   for my $obj (@$objects) {
198     if ('SL::DB::OrderItem' eq ref $obj) {
199       $obj->{shipped_qty} = $shipped_qty->{$obj->id};
200       $obj->{delivered}   = $shipped_qty->{$obj->id} == $obj->qty;
201     } elsif ('SL::DB::Order' eq ref $obj) {
202       if (exists $obj->{orderitems}) {
203         $self->write_to($obj->{orderitems});
204         $obj->{delivered} = all { $_->{delivered} } @{ $obj->{orderitems} };
205       } else {
206         # don't force a load on items. just compute by oe_id directly
207         $obj->{delivered} = $self->delivered->{$obj->id};
208       }
209     } else {
210       die "unknown reference '@{[ ref $obj ]}' for @{[ __PACKAGE__ ]}::write_to";
211     }
212   }
213 }
214
215 sub write_to_objects {
216   my ($self) = @_;
217
218   die 'Can only use write_to_objects, when calculate was called with objects. Use write_to instead.' unless $self->objects_or_ids;
219
220   $self->write_to($self->objects);
221 }
222
223 sub item_identity {
224   my ($self, $row) = @_;
225
226   join $;, map $row->{$_}, @{ $self->item_identity_fields };
227 }
228
229 sub normalize_input {
230   my ($self, $data) = @_;
231
232   $data = [$data] if 'ARRAY' ne ref $data;
233
234   $self->objects_or_ids(!!blessed($data->[0]));
235
236   if ($self->objects_or_ids) {
237     die 'unblessed object in data while expecting object' if any { !blessed($_) } @$data;
238     $self->objects($data);
239   } else {
240     die 'object or reference in data while expecting ids' if any { ref($_) } @$data;
241     $self->oe_ids($data);
242   }
243
244   $self->shipped_qty({});
245 }
246
247 sub init_oe_ids {
248   my ($self) = @_;
249
250   die 'oe_ids not initialized in id mode'            if !$self->objects_or_ids;
251   die 'objects not initialized before accessing ids' if $self->objects_or_ids && !defined $self->objects;
252
253   [ map { $_->id } @{ $self->objects } ]
254 }
255
256 sub init_dbh { SL::DB->client->dbh }
257
258 sub init_oi2oe { {} }
259 sub init_oi_qty { {} }
260 sub init_delivered {
261   my ($self) = @_;
262   my $d = { };
263   for (keys %{ $self->oi_qty }) {
264     my $oe_id = $self->oi2oe->{$_};
265     $d->{$oe_id} //= 1;
266     $d->{$oe_id} &&= $self->shipped_qty->{$_} == $self->oi_qty->{$_};
267   }
268   $d;
269 }
270
271 sub init_require_stock_out { 0 }
272 sub init_item_identity_fields { [ qw(parts_id description reqdate serialnumber) ] }
273 sub init_fill_up { 1 }
274
275 1;
276
277 __END__
278
279 =encoding utf-8
280
281 =head1 NAME
282
283 SL::Helper::ShippedQty - Algorithmic module for calculating shipped qty
284
285 =head1 SYNOPSIS
286
287   use SL::Helper::ShippedQty;
288
289   my $helper = SL::Helper::ShippedQty->new(
290     fill_up              => 0,
291     require_stock_out    => 0,
292     item_identity_fields => [ qw(parts_id description reqdate serialnumber) ],
293     set_delivered        => 1,
294   );
295
296   $helper->calculate($order_object);
297   $helper->calculate(\@order_objects);
298   $helper->calculate($oe_id);
299   $helper->calculate(\@oe_ids);
300
301   # if these are items set elivered and shipped_qty
302   # if these are orders, iterate through their items and set delivered on order
303   $helper->write_to($objects);
304
305   # if calculate was called with objects, you can use this shortcut:
306   $helper->write_to_objects;
307
308   # shipped_qtys by oi_id
309   my $shipped_qtys_by_oi_id = $helper->shipped_qtys;
310
311   # delivered by oe_id
312   my $delivered_by_oe_id = $helper->delievered;
313
314 =head1 DESCRIPTION
315
316 This module encapsulates the algorithm needed to compute the shipped qty for
317 orderitems (hopefully) correctly and efficiently for several use cases.
318
319 While this is used in object accessors, it can not be fast when called in a
320 loop over and over, so take advantage of batch processing when possible.
321
322 =head1 MOTIVATION AND PROBLEMS
323
324 The concept of shipped qty is sadly not as straight forward as it sounds on
325 first glance. Any correct implementation must in some way deal with the
326 following problems.
327
328 =over 4
329
330 =item *
331
332 When is an order shipped? For users that use the inventory it
333 will mean when a delivery order is stocked out. For those not using the
334 inventory it will mean when the delivery order is saved.
335
336 =item *
337
338 How to find the correct matching elements. After the changes
339 to record item links it's natural to assume that each position is linked, but
340 for various reasons this might not be the case. Positions that are not linked
341 in database need to be matched by marching.
342
343 =item *
344
345 Double links need to be accounted for (these can stem from buggy code).
346
347 =item *
348
349 orderitems and oe entries may link to many of their counterparts in
350 delivery_orders. delivery_orders my be created from multiple orders. The
351 only constant is that a single entry in delivery_order_items has at most one
352 link from an orderitem.
353
354 =item *
355
356 For the fill up case the identity of positions is not clear. The naive approach
357 is just the same part, but description, charge number, reqdate and qty can all
358 be part of the identity of a position for finding shipped matches.
359
360 =item *
361
362 Certain delivery orders might not be eligable for qty calculations if delivery
363 orders are used for other purposes.
364
365 =item *
366
367 Units need to be handled correctly
368
369 =item *
370
371 Negative positions must be taken into account. A negative delivery order is
372 assumed to be a RMA of sorts, but a negative order is not as straight forward.
373
374 =item *
375
376 Must be able to work with plain ids and Rose objects, and absolutely must
377 include a bulk mode to speed up multiple objects.
378
379 =back
380
381
382 =head1 FUNCTIONS
383
384 =over 4
385
386 =item C<new PARAMS>
387
388 Creates a new helper object. PARAMS may include:
389
390 =over 4
391
392 =item * C<require_stock_out>
393
394 Boolean. If set, delivery orders must be stocked out to be considered
395 delivered. The default is a client setting.
396
397 =item * C<fill_up>
398
399 Boolean. If set, unlinked delivery order items will be used to fill up
400 undelivered order items. Not needed in newer installations. The default is a
401 client setting.
402
403 =item * C<item_identity_fields ARRAY>
404
405 If set, the fields are used to compute the identity of matching positions. The
406 default is a client setting. Possible values include:
407
408 =over 4
409
410 =item * C<parts_id>
411
412 =item * C<description>
413
414 =item * C<reqdate>
415
416 =item * C<serialnumber>
417
418 =back
419
420 =back
421
422 =item C<calculate OBJECTS>
423
424 =item C<calculate IDS>
425
426 Do the main work. There must be a single argument: Either an id or an
427 C<SL::DB::Order> object, or an arrayref of one of these types.
428
429 Mixing ids and objects will generate an exception.
430
431 No return value. All internal errors will throw an exception.
432
433 =item C<write_to OBJECTS>
434
435 =item C<write_to_objects>
436
437 Save the C<shipped_qty> and C<delivered> state to the objects. If L</calculate>
438 was called with objects, then C<write_to_objects> will use these.
439
440 =item C<shipped_qty>
441
442 Valid after L</calculate>. Returns a hasref with shipped qtys by orderitems id.
443
444 =item C<delivered>
445
446 Valid after L</calculate>. Returns a hasref with delivered flag by order id.
447
448 =back
449
450 =head1 REPLACED FUNCTIONALITY
451
452 =head2 delivered mode
453
454 Originally used in mark_orders_if_delivered. Searches for orders associated
455 with a delivery order and evaluates whether those are delivered or not. No
456 detailed information is needed.
457
458 This is to be integrated into fast delivered check on the orders. The calling
459 convention for the delivery_order is not scope of this module.
460
461 =head2 do_mode
462
463 Originally used for printing delivery orders. Resolves for each position for
464 much was originally ordered, and how much remains undelivered.
465
466 This one is likely to be dropped. The information makes only sense without
467 combined merge/split deliveries and is very fragile with unaccounted delivery
468 orders.
469
470 =head2 oe mode
471
472 Same from order perspective. Used for transitions to delivery orders, where
473 delivered qtys should be removed from positions. Also used each time a record
474 is rendered to show the shipped qtys. Also used to find orders that are not
475 fully delivered.
476
477 Acceptable shortcuts would be the concepts fully shipped (for the order) and
478 providing already loaded objects.
479
480 =head2 Replaces the following functions
481
482 C<DO::get_shipped_qty>
483
484 C<SL::Controller::DeliveryPlan::calc_qtys>
485
486 C<SL::DB::OrderItem::shipped_qty>
487
488 C<SL::DB::OrderItem::delivered_qty>
489
490 =head1 OLD ALGORITHM
491
492 this is the old get_shipped_qty algorithm by Martin for reference
493
494     in: oe_id, do_id, doctype, delivered flag
495
496     not needed with better signatures
497      if do_id:
498        load oe->do links for this id,
499        set oe_ids from those
500      fi
501      if oe_id:
502        set oe_ids to this
503
504     return if no oe_ids;
505
506   2 load all orderitems for these oe_ids
507     for orderitem:
508       nomalize qty
509       set undelivered := qty
510     end
511
512     create tuple: [ position => qty_ordered, qty_not_delivered, orderitem.id ]
513
514   1 load all oe->do links for these oe_ids
515
516     if no links:
517       return all tuples so far
518     fi
519
520   4 create dictionary for orderitems from [2] by id
521
522   3 load all delivery_order_items for do_ids from [1], with recorditem_links from orderitems
523       - optionally with doctype filter (identity filter)
524
525     # first pass for record_item_links
526     for dois:
527       normalize qty
528       if link from orderitem exists and orderitem is in dictionary [4]
529         reduce qty_notdelivered in orderitem by doi.qty
530         keep link to do entry in orderitem
531     end
532
533     # second pass fill up
534     for dois:
535       ignroe if from link exists or qty == 0
536
537       for orderitems from [2]:
538         next if notdelivered_qty == 0
539         if doi.parts_id == orderitem.parts_id:
540           if oi.notdelivered_qty < 0:
541             doi :+= -oi.notdelivered_qty,
542             oi.notdelivered_qty := 0
543           else:
544             fi doi.qty < oi.notdelivered_qty:
545               doi.qty := 0
546               oi.notdelivered_qty :-= doi.qty
547             else:
548               doi.qty :-= oi.notdelivered_qty
549               oi.notdelivered_qty := 0
550             fi
551             keep link to oi in doi
552           fi
553         fi
554         last wenn doi.qty <= 0
555       end
556     end
557
558     # post process for return
559
560     if oe_id:
561       copy notdelivered from oe to ship{position}{notdelivered}
562     if !oe_id and do_id and delivered:
563       ship.{oi.trans_id}.delivered := oi.notdelivered_qty <= 0
564     if !oe_id and do_id and !delivered:
565       for all doi:
566         ignore if do.id != doi.delivery_order_id
567         if oi in doi verlinkt und position bekannt:
568           addiere oi.qty               zu doi.ordered_qty
569           addiere oi.notdelievered_qty zu doi.notdelivered_qty
570         fi
571       end
572     fi
573
574 =head1 NEW ALGORITHM
575
576   in: orders, parameters
577
578   normalize orders to ids
579
580   # handle record_item links
581   retrieve record_links entries with inner joins on orderitems, delivery_orderitems and stock/inventory if requested
582   for all record_links:
583     initialize shipped_qty for this doi to 0 if not yet seen
584     convert doi.qty to oi.unit
585     add normalized doi.qty to shipped_qty
586   end
587
588   # handle fill up
589   abort if fill up is not requested
590
591   retrieve all orderitems matching the given order ids
592   retrieve all doi with a link to the given order ids but without item link (and optionally with stock/inventory)
593   retrieve all record_links between orders and delivery_orders                  (1)
594
595   abort when no dois were found
596
597   create a partition of the delivery order items by do_id                       (2)
598   create empty mapping for delivery order items by order_id                     (3)
599   for all record_links from [1]:
600     add all matching doi from (2) to (3)
601   end
602
603   create a partition of the orderitems by item identity                         (4)
604   create a partition of the delivery order items by item identity               (5)
605
606   for each identity in (4):
607     skip if no matching entries in (5)
608
609     create partition of all orderitems for this identity by order id            (6)
610     for each sorted order id in [6]:
611       look up matching delivery order items by identity from [5]                (7)
612       look up matching delivery order items by order id from [3]                (8)
613       create stable sorted intersection between [7] and [8]                     (9)
614
615       sort the orderitems from (6) by position                                 (10)
616
617       parallel walk through [9] and [10]:
618         missing qty :=  oi.qty - shipped_qty[oi]
619
620
621         next orderitem           if missing_qty <= 0
622         next delivery order item if doi.qty == 0
623
624         min_qty := minimum(missing_qty, [doi.qty converted to oi.unit]
625
626         # transfer min_qty from doi.qty to shipped[qty]:
627         shipped_qty[oi] += min_qty
628         doi.qty         -= [min_qty converted to doi.unit]
629       end
630     end
631   end
632
633 =head1 COMPLEXITY OBSERVATIONS
634
635 Perl ops except sort are expected to be constant (relative to the op overhead).
636
637 =head2 Record item links
638
639 The query itself has indices available for all joins and filters and should
640 scale with sublinear with number of affected orderitems.
641
642 The rest of the code iterates through the result and call C<AM::convert_unit>,
643 which caches internally and is asymptotically constant.
644
645 =head2 Fill up
646
647 C<partition_by> and C<intersect> both scale linearly. The first two scale with
648 input size, but use existing indices. The delivery order items query scales
649 with the nested loop anti join of the "NOT EXISTS" subquery, which takes most
650 of the time. For large databases omitting the order id filter may be faster.
651
652 Three partitions after that scale linearly. Building the doi_by_oe_id
653 multimap is O(n²) worst case, but will be linear for most real life data.
654
655 Iterating through the values of the partitions scales with the number of
656 elements in the multimap, and does not add additional complexity.
657
658 The sort and parallel walk are O(nlogn) for the length of the subdivisions,
659 whioch again makes square worst case, but much less than that in the general
660 case.
661
662 =head3 Space requirements
663
664 In the current form the results of the 4 queries get fetched, and 4 of them are
665 held in memory at the same time. Three persistent structures are held:
666 C<shipped_qty>, C<oi2oe>, and C<oi_qty> - all hashes with one entry for each
667 orderitem. C<delivered> is calculated on demand and is a hash with an entry for
668 each order id of input.
669
670 Temporary structures are partitions of the orderitems, of which again the fill
671 up multi map between order id and delivery order items is potentially the
672 largest with square requierment worst case.
673
674
675 =head1 TODO
676
677   * delivery order identity
678   * test stocked
679   * rewrite to avoid division
680   * rewrite to avoid selectall for really large queries (no problem for up to 100k)
681   * calling mode or return to flag delivery_orders as delivered?
682   * add localized field white list
683   * reduce worst case square space requirement to linear
684
685 =head1 BUGS
686
687 None yet, but there are most likely a lot in code this funky.
688
689 =head1 AUTHOR
690
691 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
692
693 =cut