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