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