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