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