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