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