Helper::ShippedQty: orderitems korrekt in calculate behandeln
[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 uniq);
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   die 'objects need to be Order or OrderItem'        if any  {  ref($_) !~ /^SL::DB::Order(?:Item)?$/ } @{ $self->objects };
266
267   [ uniq map { ref($_) =~ /Item/ ? $_->trans_id : $_->id } @{ $self->objects } ]
268 }
269
270 sub init_dbh { SL::DB->client->dbh }
271
272 sub init_oi2oe { {} }
273 sub init_oi_qty { {} }
274 sub init_delivered {
275   my ($self) = @_;
276   my $d = { };
277   for (keys %{ $self->oi_qty }) {
278     my $oe_id = $self->oi2oe->{$_};
279     $d->{$oe_id} //= 1;
280     $d->{$oe_id} &&= $self->shipped_qty->{$_} == $self->oi_qty->{$_};
281   }
282   $d;
283 }
284
285 sub init_require_stock_out    { $::instance_conf->get_shipped_qty_require_stock_out }
286 sub init_item_identity_fields { [ grep $item_identity_fields{$_}, @{ $::instance_conf->get_shipped_qty_item_identity_fields } ] }
287 sub init_fill_up              { $::instance_conf->get_shipped_qty_fill_up  }
288
289 1;
290
291 __END__
292
293 =encoding utf-8
294
295 =head1 NAME
296
297 SL::Helper::ShippedQty - Algorithmic module for calculating shipped qty
298
299 =head1 SYNOPSIS
300
301   use SL::Helper::ShippedQty;
302
303   my $helper = SL::Helper::ShippedQty->new(
304     fill_up              => 0,
305     require_stock_out    => 0,
306     item_identity_fields => [ qw(parts_id description reqdate serialnumber) ],
307     set_delivered        => 1,
308   );
309
310   $helper->calculate($order_object);
311   $helper->calculate(\@order_objects);
312   $helper->calculate($orderitem_object);
313   $helper->calculate(\@orderitem_objects);
314   $helper->calculate($oe_id);
315   $helper->calculate(\@oe_ids);
316
317   # if these are items set elivered and shipped_qty
318   # if these are orders, iterate through their items and set delivered on order
319   $helper->write_to($objects);
320
321   # if calculate was called with objects, you can use this shortcut:
322   $helper->write_to_objects;
323
324   # shipped_qtys by oi_id
325   my $shipped_qtys_by_oi_id = $helper->shipped_qtys;
326
327   # delivered by oe_id
328   my $delivered_by_oe_id = $helper->delievered;
329
330 =head1 DESCRIPTION
331
332 This module encapsulates the algorithm needed to compute the shipped qty for
333 orderitems (hopefully) correctly and efficiently for several use cases.
334
335 While this is used in object accessors, it can not be fast when called in a
336 loop over and over, so take advantage of batch processing when possible.
337
338 =head1 MOTIVATION AND PROBLEMS
339
340 The concept of shipped qty is sadly not as straight forward as it sounds on
341 first glance. Any correct implementation must in some way deal with the
342 following problems.
343
344 =over 4
345
346 =item *
347
348 When is an order shipped? For users that use the inventory it
349 will mean when a delivery order is stocked out. For those not using the
350 inventory it will mean when the delivery order is saved.
351
352 =item *
353
354 How to find the correct matching elements. After the changes
355 to record item links it's natural to assume that each position is linked, but
356 for various reasons this might not be the case. Positions that are not linked
357 in database need to be matched by marching.
358
359 =item *
360
361 Double links need to be accounted for (these can stem from buggy code).
362
363 =item *
364
365 orderitems and oe entries may link to many of their counterparts in
366 delivery_orders. delivery_orders my be created from multiple orders. The
367 only constant is that a single entry in delivery_order_items has at most one
368 link from an orderitem.
369
370 =item *
371
372 For the fill up case the identity of positions is not clear. The naive approach
373 is just the same part, but description, charge number, reqdate and qty can all
374 be part of the identity of a position for finding shipped matches.
375
376 =item *
377
378 Certain delivery orders might not be eligable for qty calculations if delivery
379 orders are used for other purposes.
380
381 =item *
382
383 Units need to be handled correctly
384
385 =item *
386
387 Negative positions must be taken into account. A negative delivery order is
388 assumed to be a RMA of sorts, but a negative order is not as straight forward.
389
390 =item *
391
392 Must be able to work with plain ids and Rose objects, and absolutely must
393 include a bulk mode to speed up multiple objects.
394
395 =back
396
397
398 =head1 FUNCTIONS
399
400 =over 4
401
402 =item C<new PARAMS>
403
404 Creates a new helper object. PARAMS may include:
405
406 =over 4
407
408 =item * C<require_stock_out>
409
410 Boolean. If set, delivery orders must be stocked out to be considered
411 delivered. The default is a client setting.
412
413 =item * C<fill_up>
414
415 Boolean. If set, unlinked delivery order items will be used to fill up
416 undelivered order items. Not needed in newer installations. The default is a
417 client setting.
418
419 =item * C<item_identity_fields ARRAY>
420
421 If set, the fields are used to compute the identity of matching positions. The
422 default is a client setting. Possible values include:
423
424 =over 4
425
426 =item * C<parts_id>
427
428 =item * C<description>
429
430 =item * C<reqdate>
431
432 =item * C<serialnumber>
433
434 =back
435
436 =back
437
438 =item C<calculate OBJECTS>
439
440 =item C<calculate IDS>
441
442 Do the main work. There must be a single argument: Either an id or an
443 C<SL::DB::Order> object, or an arrayref of one of these types.
444
445 Mixing ids and objects will generate an exception.
446
447 No return value. All internal errors will throw an exception.
448
449 =item C<write_to OBJECTS>
450
451 =item C<write_to_objects>
452
453 Save the C<shipped_qty> and C<delivered> state to the objects. If L</calculate>
454 was called with objects, then C<write_to_objects> will use these.
455
456 =item C<shipped_qty>
457
458 Valid after L</calculate>. Returns a hasref with shipped qtys by orderitems id.
459
460 =item C<delivered>
461
462 Valid after L</calculate>. Returns a hasref with delivered flag by order id.
463
464 =back
465
466 =head1 REPLACED FUNCTIONALITY
467
468 =head2 delivered mode
469
470 Originally used in mark_orders_if_delivered. Searches for orders associated
471 with a delivery order and evaluates whether those are delivered or not. No
472 detailed information is needed.
473
474 This is to be integrated into fast delivered check on the orders. The calling
475 convention for the delivery_order is not scope of this module.
476
477 =head2 do_mode
478
479 Originally used for printing delivery orders. Resolves for each position for
480 much was originally ordered, and how much remains undelivered.
481
482 This one is likely to be dropped. The information makes only sense without
483 combined merge/split deliveries and is very fragile with unaccounted delivery
484 orders.
485
486 =head2 oe mode
487
488 Same from order perspective. Used for transitions to delivery orders, where
489 delivered qtys should be removed from positions. Also used each time a record
490 is rendered to show the shipped qtys. Also used to find orders that are not
491 fully delivered.
492
493 Acceptable shortcuts would be the concepts fully shipped (for the order) and
494 providing already loaded objects.
495
496 =head2 Replaces the following functions
497
498 C<DO::get_shipped_qty>
499
500 C<SL::Controller::DeliveryPlan::calc_qtys>
501
502 C<SL::DB::OrderItem::shipped_qty>
503
504 C<SL::DB::OrderItem::delivered_qty>
505
506 =head1 OLD ALGORITHM
507
508 this is the old get_shipped_qty algorithm by Martin for reference
509
510     in: oe_id, do_id, doctype, delivered flag
511
512     not needed with better signatures
513      if do_id:
514        load oe->do links for this id,
515        set oe_ids from those
516      fi
517      if oe_id:
518        set oe_ids to this
519
520     return if no oe_ids;
521
522   2 load all orderitems for these oe_ids
523     for orderitem:
524       nomalize qty
525       set undelivered := qty
526     end
527
528     create tuple: [ position => qty_ordered, qty_not_delivered, orderitem.id ]
529
530   1 load all oe->do links for these oe_ids
531
532     if no links:
533       return all tuples so far
534     fi
535
536   4 create dictionary for orderitems from [2] by id
537
538   3 load all delivery_order_items for do_ids from [1], with recorditem_links from orderitems
539       - optionally with doctype filter (identity filter)
540
541     # first pass for record_item_links
542     for dois:
543       normalize qty
544       if link from orderitem exists and orderitem is in dictionary [4]
545         reduce qty_notdelivered in orderitem by doi.qty
546         keep link to do entry in orderitem
547     end
548
549     # second pass fill up
550     for dois:
551       ignroe if from link exists or qty == 0
552
553       for orderitems from [2]:
554         next if notdelivered_qty == 0
555         if doi.parts_id == orderitem.parts_id:
556           if oi.notdelivered_qty < 0:
557             doi :+= -oi.notdelivered_qty,
558             oi.notdelivered_qty := 0
559           else:
560             fi doi.qty < oi.notdelivered_qty:
561               doi.qty := 0
562               oi.notdelivered_qty :-= doi.qty
563             else:
564               doi.qty :-= oi.notdelivered_qty
565               oi.notdelivered_qty := 0
566             fi
567             keep link to oi in doi
568           fi
569         fi
570         last wenn doi.qty <= 0
571       end
572     end
573
574     # post process for return
575
576     if oe_id:
577       copy notdelivered from oe to ship{position}{notdelivered}
578     if !oe_id and do_id and delivered:
579       ship.{oi.trans_id}.delivered := oi.notdelivered_qty <= 0
580     if !oe_id and do_id and !delivered:
581       for all doi:
582         ignore if do.id != doi.delivery_order_id
583         if oi in doi verlinkt und position bekannt:
584           addiere oi.qty               zu doi.ordered_qty
585           addiere oi.notdelievered_qty zu doi.notdelivered_qty
586         fi
587       end
588     fi
589
590 =head1 NEW ALGORITHM
591
592   in: orders, parameters
593
594   normalize orders to ids
595
596   # handle record_item links
597   retrieve record_links entries with inner joins on orderitems, delivery_orderitems and stock/inventory if requested
598   for all record_links:
599     initialize shipped_qty for this doi to 0 if not yet seen
600     convert doi.qty to oi.unit
601     add normalized doi.qty to shipped_qty
602   end
603
604   # handle fill up
605   abort if fill up is not requested
606
607   retrieve all orderitems matching the given order ids
608   retrieve all doi with a link to the given order ids but without item link (and optionally with stock/inventory)
609   retrieve all record_links between orders and delivery_orders                  (1)
610
611   abort when no dois were found
612
613   create a partition of the delivery order items by do_id                       (2)
614   create empty mapping for delivery order items by order_id                     (3)
615   for all record_links from [1]:
616     add all matching doi from (2) to (3)
617   end
618
619   create a partition of the orderitems by item identity                         (4)
620   create a partition of the delivery order items by item identity               (5)
621
622   for each identity in (4):
623     skip if no matching entries in (5)
624
625     create partition of all orderitems for this identity by order id            (6)
626     for each sorted order id in [6]:
627       look up matching delivery order items by identity from [5]                (7)
628       look up matching delivery order items by order id from [3]                (8)
629       create stable sorted intersection between [7] and [8]                     (9)
630
631       sort the orderitems from (6) by position                                 (10)
632
633       parallel walk through [9] and [10]:
634         missing qty :=  oi.qty - shipped_qty[oi]
635
636
637         next orderitem           if missing_qty <= 0
638         next delivery order item if doi.qty == 0
639
640         min_qty := minimum(missing_qty, [doi.qty converted to oi.unit]
641
642         # transfer min_qty from doi.qty to shipped[qty]:
643         shipped_qty[oi] += min_qty
644         doi.qty         -= [min_qty converted to doi.unit]
645       end
646     end
647   end
648
649 =head1 COMPLEXITY OBSERVATIONS
650
651 Perl ops except sort are expected to be constant (relative to the op overhead).
652
653 =head2 Record item links
654
655 The query itself has indices available for all joins and filters and should
656 scale with sublinear with number of affected orderitems.
657
658 The rest of the code iterates through the result and call C<AM::convert_unit>,
659 which caches internally and is asymptotically constant.
660
661 =head2 Fill up
662
663 C<partition_by> and C<intersect> both scale linearly. The first two scale with
664 input size, but use existing indices. The delivery order items query scales
665 with the nested loop anti join of the "NOT EXISTS" subquery, which takes most
666 of the time. For large databases omitting the order id filter may be faster.
667
668 Three partitions after that scale linearly. Building the doi_by_oe_id
669 multimap is O(n²) worst case, but will be linear for most real life data.
670
671 Iterating through the values of the partitions scales with the number of
672 elements in the multimap, and does not add additional complexity.
673
674 The sort and parallel walk are O(nlogn) for the length of the subdivisions,
675 whioch again makes square worst case, but much less than that in the general
676 case.
677
678 =head3 Space requirements
679
680 In the current form the results of the 4 queries get fetched, and 4 of them are
681 held in memory at the same time. Three persistent structures are held:
682 C<shipped_qty>, C<oi2oe>, and C<oi_qty> - all hashes with one entry for each
683 orderitem. C<delivered> is calculated on demand and is a hash with an entry for
684 each order id of input.
685
686 Temporary structures are partitions of the orderitems, of which again the fill
687 up multi map between order id and delivery order items is potentially the
688 largest with square requierment worst case.
689
690
691 =head1 TODO
692
693   * delivery order identity
694   * test stocked
695   * rewrite to avoid division
696   * rewrite to avoid selectall for really large queries (no problem for up to 100k)
697   * calling mode or return to flag delivery_orders as delivered?
698   * add localized field white list
699   * reduce worst case square space requirement to linear
700
701 =head1 BUGS
702
703 None yet, but there are most likely a lot in code this funky.
704
705 =head1 AUTHOR
706
707 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
708
709 =cut