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