1 package SL::Helper::ShippedQty;
4 use parent qw(Rose::Object);
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);
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) ],
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
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
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
31 WHERE oi.trans_id IN (%s)
32 ORDER BY oi.trans_id, oi.position
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 (
43 AND to_table = 'delivery_orders'
44 AND to_id = doi.delivery_order_id)
48 WHERE from_table = 'orderitems'
49 AND to_table = 'delivery_order_items'
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, i.qty AS doi_qty, p.unit AS doi_unit
55 INNER JOIN orderitems oi ON oi.id = rl.from_id AND rl.from_table = 'orderitems'
56 INNER JOIN delivery_order_items doi ON doi.id = rl.to_id AND rl.to_table = 'delivery_order_items'
57 INNER JOIN delivery_order_items_stock dois ON dois.delivery_order_item_id = doi.id
58 INNER JOIN inventory i ON dois.id = i.delivery_order_items_stock_id
59 INNER JOIN parts p ON p.id = doi.parts_id
60 WHERE oi.trans_id IN (%s)
61 ORDER BY oi.trans_id, oi.position
63 my $stock_fill_up_doi_query = <<'';
64 SELECT doi.id, doi.delivery_order_id, doi.position, doi.parts_id, doi.description, doi.reqdate, doi.serialnumber, i.qty, i.unit
65 FROM delivery_order_items doi
66 INNER JOIN parts p ON p.id = doi.parts_id
67 INNER JOIN delivery_order_items_stock dois ON dois.delivery_order_item_id = doi.id
68 INNER JOIN inventory i ON dois.id = i.delivery_order_items_stock_id
69 WHERE doi.delivery_order_id IN (
74 AND to_table = 'delivery_orders'
75 AND to_id = doi.delivery_order_id)
79 WHERE from_table = 'orderitems'
80 AND to_table = 'delivery_order_items'
83 my $oe_do_record_links = <<'';
88 AND to_table = 'delivery_orders'
90 my @known_item_identity_fields = qw(parts_id description reqdate serialnumber);
91 my %item_identity_fields = (
92 parts_id => t8('Part'),
93 description => t8('Description'),
94 reqdate => t8('Reqdate'),
95 serialnumber => t8('Serial Number'),
99 my ($self, $data) = @_;
101 die 'Need exactly one argument, either id, object or arrayref of ids or objects.' unless 2 == @_;
103 return if !$data || ('ARRAY' eq ref $data && !@$data);
105 $self->normalize_input($data);
107 return unless @{ $self->oe_ids };
109 $self->calculate_item_links;
110 $self->calculate_fill_up if $self->fill_up;
113 sub calculate_item_links {
116 my @oe_ids = @{ $self->oe_ids };
118 my $item_links_query = $self->require_stock_out ? $stock_item_links_query : $no_stock_item_links_query;
120 my $query = sprintf $item_links_query, join (', ', ('?')x @oe_ids);
122 my $data = selectall_hashref_query($::form, $self->dbh, $query, @oe_ids);
125 $self->shipped_qty->{$_->{oi_id}} //= 0;
126 $self->shipped_qty->{$_->{oi_id}} += $_->{doi_qty} * AM->convert_unit($_->{doi_unit} => $_->{oi_unit});
127 $self->oi2oe->{$_->{oi_id}} = $_->{trans_id};
128 $self->oi_qty->{$_->{oi_id}} = $_->{oi_qty};
135 grep { $seen{$_}++ } @$a1, @$a2;
138 sub calculate_fill_up {
141 my @oe_ids = @{ $self->oe_ids };
143 my $fill_up_doi_query = $self->require_stock_out ? $stock_fill_up_doi_query : $no_stock_fill_up_doi_query;
145 my $oi_query = sprintf $fill_up_oi_query, join (', ', ('?')x@oe_ids);
146 my $doi_query = sprintf $fill_up_doi_query, join (', ', ('?')x@oe_ids);
147 my $rl_query = sprintf $oe_do_record_links, join (', ', ('?')x@oe_ids);
149 my $oi = selectall_hashref_query($::form, $self->dbh, $oi_query, @oe_ids);
153 my $doi = selectall_hashref_query($::form, $self->dbh, $doi_query, @oe_ids);
154 my $rl = selectall_hashref_query($::form, $self->dbh, $rl_query, @oe_ids);
156 my %oi_by_identity = partition_by { $self->item_identity($_) } @$oi;
157 my %doi_by_id = partition_by { $_->{delivery_order_id} } @$doi;
159 push @{ $doi_by_trans_id{$_->{from_id}} //= [] }, @{ $doi_by_id{$_->{to_id}} }
160 for grep { exists $doi_by_id{$_->{to_id}} } @$rl;
162 my %doi_by_identity = partition_by { $self->item_identity($_) } @$doi;
164 for my $match (sort keys %oi_by_identity) {
165 next unless exists $doi_by_identity{$match};
167 my %oi_by_oe = partition_by { $_->{trans_id} } @{ $oi_by_identity{$match} };
168 for my $trans_id (sort { $a <=> $b } keys %oi_by_oe) {
169 next unless my @sorted_doi = _intersect($doi_by_identity{$match}, $doi_by_trans_id{$trans_id});
171 # sorting should be quite fast here, because there are usually only a handful of matches
172 next unless my @sorted_oi = sort { $a->{position} <=> $b->{position} } @{ $oi_by_oe{$trans_id} };
174 # parallel walk through sorted oi/doi entries
175 my $oi_i = my $doi_i = 0;
176 my ($oi, $doi) = ($sorted_oi[$oi_i], $sorted_doi[$doi_i]);
177 while ($oi_i < @sorted_oi && $doi_i < @sorted_doi) {
178 $oi = $sorted_oi[++$oi_i], next if $oi->{qty} <= $self->shipped_qty->{$oi->{id}};
179 $doi = $sorted_doi[++$doi_i], next if 0 == $doi->{qty};
181 my $factor = AM->convert_unit($doi->{unit} => $oi->{unit});
182 my $min_qty = min($oi->{qty} - $self->shipped_qty->{$oi->{id}}, $doi->{qty} * $factor);
184 # min_qty should never be 0 now. the first part triggers the first next,
185 # the second triggers the second next and factor must not be 0
186 # but it would lead to an infinite loop, so catch that.
187 die 'panic! invalid shipping quantity' unless $min_qty;
189 $self->shipped_qty->{$oi->{id}} += $min_qty;
190 $doi->{qty} -= $min_qty / $factor; # TODO: find a way to avoid float rounding
195 $self->oi2oe->{$_->{id}} = $_->{trans_id} for @$oi;
196 $self->oi_qty->{$_->{id}} = $_->{qty} for @$oi;
200 my ($self, $objects) = @_;
202 die 'expecting array of objects' unless 'ARRAY' eq ref $objects;
204 my $shipped_qty = $self->shipped_qty;
206 for my $obj (@$objects) {
207 if ('SL::DB::OrderItem' eq ref $obj) {
208 $obj->{shipped_qty} = $shipped_qty->{$obj->id};
209 $obj->{delivered} = $shipped_qty->{$obj->id} == $obj->qty;
210 } elsif ('SL::DB::Order' eq ref $obj) {
211 if (exists $obj->{orderitems}) {
212 $self->write_to($obj->{orderitems});
213 $obj->{delivered} = all { $_->{delivered} } @{ $obj->{orderitems} };
215 # don't force a load on items. just compute by oe_id directly
216 $obj->{delivered} = $self->delivered->{$obj->id};
219 die "unknown reference '@{[ ref $obj ]}' for @{[ __PACKAGE__ ]}::write_to";
224 sub write_to_objects {
227 die 'Can only use write_to_objects, when calculate was called with objects. Use write_to instead.' unless $self->objects_or_ids;
229 $self->write_to($self->objects);
233 my ($self, $row) = @_;
235 join $;, map $row->{$_}, @{ $self->item_identity_fields };
238 sub normalize_input {
239 my ($self, $data) = @_;
241 $data = [$data] if 'ARRAY' ne ref $data;
243 $self->objects_or_ids(!!blessed($data->[0]));
245 if ($self->objects_or_ids) {
246 die 'unblessed object in data while expecting object' if any { !blessed($_) } @$data;
247 $self->objects($data);
249 die 'object or reference in data while expecting ids' if any { ref($_) } @$data;
250 $self->oe_ids($data);
253 $self->shipped_qty({});
256 sub available_item_identity_fields {
257 map { [ $_ => $item_identity_fields{$_} ] } @known_item_identity_fields;
263 die 'oe_ids not initialized in id mode' if !$self->objects_or_ids;
264 die 'objects not initialized before accessing ids' if $self->objects_or_ids && !defined $self->objects;
265 die 'objects need to be Order or OrderItem' if any { ref($_) !~ /^SL::DB::Order(?:Item)?$/ } @{ $self->objects };
267 [ uniq map { ref($_) =~ /Item/ ? $_->trans_id : $_->id } @{ $self->objects } ]
270 sub init_dbh { SL::DB->client->dbh }
272 sub init_oi2oe { {} }
273 sub init_oi_qty { {} }
277 for (keys %{ $self->oi_qty }) {
278 my $oe_id = $self->oi2oe->{$_};
280 $d->{$oe_id} &&= $self->shipped_qty->{$_} == $self->oi_qty->{$_};
285 sub init_require_stock_out { $::instance_conf->get_shipped_qty_require_stock_out }
286 sub init_item_identity_fields { [ grep $item_identity_fields{$_}, @{ $::instance_conf->get_shipped_qty_item_identity_fields } ] }
287 sub init_fill_up { $::instance_conf->get_shipped_qty_fill_up }
297 SL::Helper::ShippedQty - Algorithmic module for calculating shipped qty
301 use SL::Helper::ShippedQty;
303 my $helper = SL::Helper::ShippedQty->new(
305 require_stock_out => 0,
306 item_identity_fields => [ qw(parts_id description reqdate serialnumber) ],
310 $helper->calculate($order_object);
311 $helper->calculate(\@order_objects);
312 $helper->calculate($orderitem_object);
313 $helper->calculate(\@orderitem_objects);
314 $helper->calculate($oe_id);
315 $helper->calculate(\@oe_ids);
317 # if these are items set elivered and shipped_qty
318 # if these are orders, iterate through their items and set delivered on order
319 $helper->write_to($objects);
321 # if calculate was called with objects, you can use this shortcut:
322 $helper->write_to_objects;
324 # shipped_qtys by oi_id
325 my $shipped_qtys_by_oi_id = $helper->shipped_qtys;
328 my $delivered_by_oe_id = $helper->delievered;
332 This module encapsulates the algorithm needed to compute the shipped qty for
333 orderitems (hopefully) correctly and efficiently for several use cases.
335 While this is used in object accessors, it can not be fast when called in a
336 loop over and over, so take advantage of batch processing when possible.
338 =head1 MOTIVATION AND PROBLEMS
340 The concept of shipped qty is sadly not as straight forward as it sounds on
341 first glance. Any correct implementation must in some way deal with the
348 When is an order shipped? For users that use the inventory it
349 will mean when a delivery order is stocked out. For those not using the
350 inventory it will mean when the delivery order is saved.
354 How to find the correct matching elements. After the changes
355 to record item links it's natural to assume that each position is linked, but
356 for various reasons this might not be the case. Positions that are not linked
357 in database need to be matched by marching.
361 Double links need to be accounted for (these can stem from buggy code).
365 orderitems and oe entries may link to many of their counterparts in
366 delivery_orders. delivery_orders my be created from multiple orders. The
367 only constant is that a single entry in delivery_order_items has at most one
368 link from an orderitem.
372 For the fill up case the identity of positions is not clear. The naive approach
373 is just the same part, but description, charge number, reqdate and qty can all
374 be part of the identity of a position for finding shipped matches.
378 Certain delivery orders might not be eligable for qty calculations if delivery
379 orders are used for other purposes.
383 Units need to be handled correctly
387 Negative positions must be taken into account. A negative delivery order is
388 assumed to be a RMA of sorts, but a negative order is not as straight forward.
392 Must be able to work with plain ids and Rose objects, and absolutely must
393 include a bulk mode to speed up multiple objects.
404 Creates a new helper object. PARAMS may include:
408 =item * C<require_stock_out>
410 Boolean. If set, delivery orders must be stocked out to be considered
411 delivered. The default is a client setting.
415 Boolean. If set, unlinked delivery order items will be used to fill up
416 undelivered order items. Not needed in newer installations. The default is a
419 =item * C<item_identity_fields ARRAY>
421 If set, the fields are used to compute the identity of matching positions. The
422 default is a client setting. Possible values include:
428 =item * C<description>
432 =item * C<serialnumber>
438 =item C<calculate OBJECTS>
440 =item C<calculate IDS>
442 Do the main work. There must be a single argument: Either an id or an
443 C<SL::DB::Order> object, or an arrayref of one of these types.
445 Mixing ids and objects will generate an exception.
447 No return value. All internal errors will throw an exception.
449 =item C<write_to OBJECTS>
451 =item C<write_to_objects>
453 Save the C<shipped_qty> and C<delivered> state to the objects. If L</calculate>
454 was called with objects, then C<write_to_objects> will use these.
458 Valid after L</calculate>. Returns a hasref with shipped qtys by orderitems id.
462 Valid after L</calculate>. Returns a hasref with delivered flag by order id.
466 =head1 REPLACED FUNCTIONALITY
468 =head2 delivered mode
470 Originally used in mark_orders_if_delivered. Searches for orders associated
471 with a delivery order and evaluates whether those are delivered or not. No
472 detailed information is needed.
474 This is to be integrated into fast delivered check on the orders. The calling
475 convention for the delivery_order is not scope of this module.
479 Originally used for printing delivery orders. Resolves for each position for
480 much was originally ordered, and how much remains undelivered.
482 This one is likely to be dropped. The information makes only sense without
483 combined merge/split deliveries and is very fragile with unaccounted delivery
488 Same from order perspective. Used for transitions to delivery orders, where
489 delivered qtys should be removed from positions. Also used each time a record
490 is rendered to show the shipped qtys. Also used to find orders that are not
493 Acceptable shortcuts would be the concepts fully shipped (for the order) and
494 providing already loaded objects.
496 =head2 Replaces the following functions
498 C<DO::get_shipped_qty>
500 C<SL::Controller::DeliveryPlan::calc_qtys>
502 C<SL::DB::OrderItem::shipped_qty>
504 C<SL::DB::OrderItem::delivered_qty>
508 this is the old get_shipped_qty algorithm by Martin for reference
510 in: oe_id, do_id, doctype, delivered flag
512 not needed with better signatures
514 load oe->do links for this id,
515 set oe_ids from those
522 2 load all orderitems for these oe_ids
525 set undelivered := qty
528 create tuple: [ position => qty_ordered, qty_not_delivered, orderitem.id ]
530 1 load all oe->do links for these oe_ids
533 return all tuples so far
536 4 create dictionary for orderitems from [2] by id
538 3 load all delivery_order_items for do_ids from [1], with recorditem_links from orderitems
539 - optionally with doctype filter (identity filter)
541 # first pass for record_item_links
544 if link from orderitem exists and orderitem is in dictionary [4]
545 reduce qty_notdelivered in orderitem by doi.qty
546 keep link to do entry in orderitem
549 # second pass fill up
551 ignroe if from link exists or qty == 0
553 for orderitems from [2]:
554 next if notdelivered_qty == 0
555 if doi.parts_id == orderitem.parts_id:
556 if oi.notdelivered_qty < 0:
557 doi :+= -oi.notdelivered_qty,
558 oi.notdelivered_qty := 0
560 fi doi.qty < oi.notdelivered_qty:
562 oi.notdelivered_qty :-= doi.qty
564 doi.qty :-= oi.notdelivered_qty
565 oi.notdelivered_qty := 0
567 keep link to oi in doi
570 last wenn doi.qty <= 0
574 # post process for return
577 copy notdelivered from oe to ship{position}{notdelivered}
578 if !oe_id and do_id and delivered:
579 ship.{oi.trans_id}.delivered := oi.notdelivered_qty <= 0
580 if !oe_id and do_id and !delivered:
582 ignore if do.id != doi.delivery_order_id
583 if oi in doi verlinkt und position bekannt:
584 addiere oi.qty zu doi.ordered_qty
585 addiere oi.notdelievered_qty zu doi.notdelivered_qty
592 in: orders, parameters
594 normalize orders to ids
596 # handle record_item links
597 retrieve record_links entries with inner joins on orderitems, delivery_orderitems and stock/inventory if requested
598 for all record_links:
599 initialize shipped_qty for this doi to 0 if not yet seen
600 convert doi.qty to oi.unit
601 add normalized doi.qty to shipped_qty
605 abort if fill up is not requested
607 retrieve all orderitems matching the given order ids
608 retrieve all doi with a link to the given order ids but without item link (and optionally with stock/inventory)
609 retrieve all record_links between orders and delivery_orders (1)
611 abort when no dois were found
613 create a partition of the delivery order items by do_id (2)
614 create empty mapping for delivery order items by order_id (3)
615 for all record_links from [1]:
616 add all matching doi from (2) to (3)
619 create a partition of the orderitems by item identity (4)
620 create a partition of the delivery order items by item identity (5)
622 for each identity in (4):
623 skip if no matching entries in (5)
625 create partition of all orderitems for this identity by order id (6)
626 for each sorted order id in [6]:
627 look up matching delivery order items by identity from [5] (7)
628 look up matching delivery order items by order id from [3] (8)
629 create stable sorted intersection between [7] and [8] (9)
631 sort the orderitems from (6) by position (10)
633 parallel walk through [9] and [10]:
634 missing qty := oi.qty - shipped_qty[oi]
637 next orderitem if missing_qty <= 0
638 next delivery order item if doi.qty == 0
640 min_qty := minimum(missing_qty, [doi.qty converted to oi.unit]
642 # transfer min_qty from doi.qty to shipped[qty]:
643 shipped_qty[oi] += min_qty
644 doi.qty -= [min_qty converted to doi.unit]
649 =head1 COMPLEXITY OBSERVATIONS
651 Perl ops except sort are expected to be constant (relative to the op overhead).
653 =head2 Record item links
655 The query itself has indices available for all joins and filters and should
656 scale with sublinear with number of affected orderitems.
658 The rest of the code iterates through the result and call C<AM::convert_unit>,
659 which caches internally and is asymptotically constant.
663 C<partition_by> and C<intersect> both scale linearly. The first two scale with
664 input size, but use existing indices. The delivery order items query scales
665 with the nested loop anti join of the "NOT EXISTS" subquery, which takes most
666 of the time. For large databases omitting the order id filter may be faster.
668 Three partitions after that scale linearly. Building the doi_by_oe_id
669 multimap is O(n²) worst case, but will be linear for most real life data.
671 Iterating through the values of the partitions scales with the number of
672 elements in the multimap, and does not add additional complexity.
674 The sort and parallel walk are O(nlogn) for the length of the subdivisions,
675 whioch again makes square worst case, but much less than that in the general
678 =head3 Space requirements
680 In the current form the results of the 4 queries get fetched, and 4 of them are
681 held in memory at the same time. Three persistent structures are held:
682 C<shipped_qty>, C<oi2oe>, and C<oi_qty> - all hashes with one entry for each
683 orderitem. C<delivered> is calculated on demand and is a hash with an entry for
684 each order id of input.
686 Temporary structures are partitions of the orderitems, of which again the fill
687 up multi map between order id and delivery order items is potentially the
688 largest with square requierment worst case.
693 * delivery order identity
695 * rewrite to avoid division
696 * rewrite to avoid selectall for really large queries (no problem for up to 100k)
697 * calling mode or return to flag delivery_orders as delivered?
698 * add localized field white list
699 * reduce worst case square space requirement to linear
703 None yet, but there are most likely a lot in code this funky.
707 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>