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