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