1 package SL::Helper::Inventory;
6 use Exporter qw(import);
7 use List::Util qw(min sum);
8 use List::UtilsBy qw(sort_by);
9 use List::MoreUtils qw(any);
12 use SL::Locale::String qw(t8);
13 use SL::MoreCommon qw(listify);
14 use SL::DBUtils qw(selectall_hashref_query selectrow_query);
15 use SL::DB::TransferType;
16 use SL::Helper::Number qw(_round_qty _qty);
19 our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly check_constraints);
20 our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
22 sub _get_stock_onhand {
25 my $onhand_mode = !!$params{onhand};
29 'MIN(EXTRACT(epoch FROM inventory.itime)) AS itime',
36 my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
37 push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
42 my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
43 push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
47 if ($params{warehouse}) {
48 my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
49 push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
53 if ($params{chargenumber}) {
54 my @ids = listify($params{chargenumber});
55 push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
60 push @where, sprintf "shippingdate <= ?";
61 push @values, $params{date};
64 if ($params{bestbefore}) {
65 push @where, sprintf "bestbefore >= ?";
66 push @values, $params{bestbefore};
70 if ($params{onhand} && !$params{warehouse}) {
71 push @where, 'NOT warehouse.forreserve';
75 if ($params{onhand} && !$params{reserve_for}) {
76 push @where, 'reserve_for_id IS NULL AND reserve_for_table IS NULL';
79 if ($params{reserve_for}) {
80 my @objects = listify($params{reserve_for});
82 push @tokens, ( "(reserve_for_id = ? AND reserve_for_table = ?)") x @objects;
83 push @values, map { ($_->id, $_->meta->table) } @objects;
84 push @where, '(' . join(' OR ', @tokens) . ')';
89 part => [ qw(parts_id) ],
90 bin => [ qw(bin_id inventory.warehouse_id warehouse.forreserve)],
91 warehouse => [ qw(inventory.warehouse_id warehouse.forreserve) ],
92 chargenumber => [ qw(chargenumber) ],
93 bestbefore => [ qw(bestbefore) ],
94 reserve_for => [ qw(reserve_for_id reserve_for_table) ],
95 for_allocate => [ qw(parts_id bin_id inventory.warehouse_id warehouse.forreserve chargenumber bestbefore reserve_for_id reserve_for_table) ],
99 for (listify($params{by})) {
100 my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_");
101 push @selects, @$selects;
102 push @groups, @$selects;
106 my $select = join ',', @selects;
107 my $where = @where ? 'WHERE ' . join ' AND ', @where : '';
108 my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
111 SELECT $select FROM inventory
112 LEFT JOIN bin ON bin_id = bin.id
113 LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
118 my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
121 part => 'SL::DB::Manager::Part',
122 bin => 'SL::DB::Manager::Bin',
123 warehouse => 'SL::DB::Manager::Warehouse',
124 reserve_for => undef,
130 warehouse => 'warehouse_id',
133 if ($params{by} && $params{with_objects}) {
134 for my $with_object (listify($params{with_objects})) {
135 Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object};
137 if (my $manager = $with_objects{$with_object}) {
138 my $slot = $slots{$with_object};
139 next if !(my @ids = map { $_->{$slot} } @$results);
140 my $objects = $manager->get_all(query => [ id => \@ids ]);
141 my %objects_by_id = map { $_->id => $_ } @$objects;
143 $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
145 # need to fetch all reserve_for_table partitions
153 return $results->[0]{qty};
158 _get_stock_onhand(@_, onhand => 0);
162 _get_stock_onhand(@_, onhand => 1);
168 my $part = $params{part} or Carp::croak('allocate needs a part');
169 my $qty = $params{qty} or Carp::croak('allocate needs a qty');
171 return () if $qty <= 0;
173 my $results = get_stock(part => $part, by => 'for_allocate');
174 my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{bin});
175 my %wh_whitelist = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{warehouse});
176 my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{chargenumber});
177 my %reserve_whitelist;
178 if ($params{reserve_for}) {
179 $reserve_whitelist{ $_->meta->table }{ $_->id } = 1 for listify($params{reserve_for});
182 # filter the results. we don't want:
184 # - bins that are reserve but not in the white-list of warehouses or bins
185 # - reservations that are not white-listed
187 my @filtered_results = grep {
188 (!$_->{forreserve} || $bin_whitelist{$_->{bin_id}} || $wh_whitelist{$_->{warehouse_id}})
189 && (!$_->{reserve_for_id} || $reserve_whitelist{ $_->{reserve_for_table} }{ $_->{reserve_for_id} })
192 # sort results so that reserve_for is first, then chargenumbers, then wanted bins, then wanted warehouses
193 my @sorted_results = sort {
194 (!!$b->{reserve_for_id}) <=> (!!$a->{reserve_for_id}) # sort by existing reserve_for_id first.
195 || exists $chargenumbers{$b->{chargenumber}} <=> exists $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers
196 || exists $bin_whitelist{$b->{bin_id}} <=> exists $bin_whitelist{$a->{bin_id}} # then prefer wanted bins
197 || exists $wh_whitelist{$b->{warehouse_id}} <=> exists $wh_whitelist{$a->{warehouse_id}} # then prefer wanted bins
198 || $a->{itime} <=> $b->{itime} # and finally prefer earlier charges
203 for my $chunk (@sorted_results) {
204 my $qty = min($chunk->{qty}, $rest_qty);
206 push @allocations, SL::Helper::Inventory::Allocation->new(
207 parts_id => $chunk->{parts_id},
209 comment => $params{comment},
210 bin_id => $chunk->{bin_id},
211 warehouse_id => $chunk->{warehouse_id},
212 chargenumber => $chunk->{chargenumber},
213 bestbefore => $chunk->{bestbefore},
214 reserve_for_id => $chunk->{reserve_for_id},
215 reserve_for_table => $chunk->{reserve_for_table},
216 for_object_id => undef,
218 $rest_qty -= _round_qty($qty);
220 $rest_qty = _round_qty($rest_qty);
221 last if $rest_qty == 0;
224 die SL::X::Inventory::Allocation->new(
225 error => 'not enough to allocate',
226 msg => t8("can not allocate #1 units of #2, missing #3 units", _qty($qty), $part->displayable_name, _qty($rest_qty)),
229 if ($params{constraints}) {
230 check_constraints($params{constraints},\@allocations);
236 sub allocate_for_assembly {
239 my $part = $params{part} or Carp::croak('allocate needs a part');
240 my $qty = $params{qty} or Carp::croak('allocate needs a qty');
242 Carp::croak('not an assembly') unless $part->is_assembly;
244 my %parts_to_allocate;
246 for my $assembly ($part->assemblies) {
247 next if $assembly->part->dispotype eq 'no_stock';
249 my $tmpqty = $assembly->assembly_part->is_recipe ? $assembly->qty * $qty / $assembly->assembly_part->scalebasis
250 : $assembly->part->unit eq 'Stck' ? ceil($assembly->qty * $qty)
251 : $assembly->qty * $qty;
252 $parts_to_allocate{ $assembly->part->id } //= 0;
253 $parts_to_allocate{ $assembly->part->id } += $tmpqty;
258 for my $part_id (keys %parts_to_allocate) {
259 my $part = SL::DB::Part->load_cached($part_id);
260 push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id});
266 sub check_constraints {
267 my ($constraints, $allocations) = @_;
268 if ('CODE' eq ref $constraints) {
269 if (!$constraints->(@$allocations)) {
270 die SL::X::Inventory::Allocation->new(
271 error => 'allocation constraints failure',
272 msg => t8("Allocations didn't pass constraints"),
276 croak 'constraints needs to be a hashref' unless 'HASH' eq ref $constraints;
278 my %supported_constraints = (
280 warehouse_id => 'warehouse_id',
281 chargenumber => 'chargenumber',
284 for (keys %$constraints ) {
285 croak "unsupported constraint '$_'" unless $supported_constraints{$_};
286 next unless defined $constraints->{$_};
288 my %whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($constraints->{$_});
289 my $accessor = $supported_constraints{$_};
291 if (any { !$whitelist{$_->$accessor} } @$allocations) {
292 my %error_constraints = (
293 bin_id => t8('Bins'),
294 warehouse_id => t8('Warehouses'),
295 chargenumber => t8('Chargenumbers'),
297 my @allocs = grep { $whitelist{$_->$accessor} } @$allocations;
298 my $needed = sum map { $_->qty } grep { !$whitelist{$_->$accessor} } @$allocations;
299 my $err = t8("Cannot allocate parts.");
300 $err .= ' '.t8('part \'#\'1 in bin \'#2\' only with qty #3 (need additional #4) and chargenumber \'#5\'.',
301 SL::DB::Part->load_cached($_->parts_id)->description,
302 SL::DB::Bin->load_cached($_->bin_id)->full_description,
303 _qty($_->qty), _qty($needed), $_->chargenumber ? $_->chargenumber : '--') for @allocs;
304 die SL::X::Inventory::Allocation->new(
305 error => 'allocation constraints failure',
313 sub produce_assembly {
316 my $part = $params{part} or Carp::croak('produce_assembly needs a part');
317 my $qty = $params{qty} or Carp::croak('produce_assembly needs a qty');
319 my $allocations = $params{allocations};
320 if ($params{auto_allocate}) {
321 Carp::croak("produce_assembly: can't have both allocations and auto_allocate") if $params{allocations};
322 $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ];
324 Carp::croak("produce_assembly: need allocations or auto_allocate to produce something") if !$params{allocations};
325 $allocations = $params{allocations};
328 my $bin = $params{bin} or Carp::croak("need target bin");
329 my $chargenumber = $params{chargenumber};
330 my $bestbefore = $params{bestbefore};
331 my $for_object_id = $params{for_object_id};
332 my $comment = $params{comment} // '';
334 my $production_order_item = $params{production_order_item};
335 my $invoice = $params{invoice};
336 my $project = $params{project};
337 my $reserve_for = $params{reserve_for};
339 my $reserve_for_id = $reserve_for ? $reserve_for->id : undef;
340 my $reserve_for_table = $reserve_for ? $reserve_for->meta->table : undef;
342 my $shippingdate = $params{shippingsdate} // DateTime->now_local;
344 my $trans_id = $params{trans_id};
345 ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
347 my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used');
348 my $trans_type_in = SL::DB::Manager::TransferType->find_by(direction => 'in', description => 'assembled');
350 # check whether allocations are sane
351 if (!$params{no_check_allocations} && !$params{auto_allocate}) {
352 my %allocations_by_part = map { $_->parts_id => $_->qty } @$allocations;
353 for my $assembly ($part->assemblies) {
354 $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty; # TODO recipe factor
357 die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part;
361 for my $allocation (@$allocations) {
362 my $oe_id = delete $allocation->{for_object_id};
363 push @transfers, SL::DB::Inventory->new(
364 trans_id => $trans_id,
366 qty => -$allocation->qty,
367 trans_type => $trans_type_out,
368 shippingdate => $shippingdate,
369 employee => SL::DB::Manager::Employee->current,
370 oe_id => $allocation->for_object_id,
374 push @transfers, SL::DB::Inventory->new(
375 trans_id => $trans_id,
376 trans_type => $trans_type_in,
380 warehouse => $bin->warehouse_id,
381 chargenumber => $chargenumber,
382 bestbefore => $bestbefore,
383 reserve_for_id => $reserve_for_id,
384 reserve_for_table => $reserve_for_table,
385 shippingdate => $shippingdate,
389 prod => $production_order_item,
390 employee => SL::DB::Manager::Employee->current,
391 oe_id => $for_object_id,
394 SL::DB->client->with_transaction(sub {
395 $_->save for @transfers;
398 die SL::DB->client->error;
404 package SL::Helper::Inventory::Allocation {
405 my @attributes = qw(parts_id qty bin_id warehouse_id chargenumber bestbefore comment reserve_for_id reserve_for_table for_object_id);
406 my %attributes = map { $_ => 1 } @attributes;
408 for my $name (@attributes) {
410 *{"SL::Helper::Inventory::Allocation::$name"} = sub { $_[0]{$name} };
414 my ($class, %params) = @_;
416 Carp::croak("missing attribute $_") for grep { !exists $params{$_} } @attributes;
417 Carp::croak("unknown attribute $_") for grep { !exists $attributes{$_} } keys %params;
418 Carp::croak("$_ must be set") for grep { !$params{$_} } qw(parts_id qty bin_id);
419 Carp::croak("$_ must be positive") for grep { !($params{$_} > 0) } qw(parts_id qty bin_id);
421 bless { %params }, $class;
431 SL::WH - Warehouse and Inventory API
435 # See description for an intro to the concepts used here.
437 use SL::Helper::Inventory qw(:ALL);
439 # stock, get "what's there" for a part with various conditions:
440 my $qty = get_stock(part => $part); # how much is on stock?
441 my $qty = get_stock(part => $part, date => $date); # how much was on stock at a specific time?
442 my $qty = get_stock(part => $part, bin => $bin); # how is on stock in a specific bin?
443 my $qty = get_stock(part => $part, warehouse => $warehouse); # how is on stock in a specific warehouse?
444 my $qty = get_stock(part => $part, chargenumber => $chargenumber); # how is on stock of a specific chargenumber?
446 # onhand, get "what's available" for a part with various conditions:
447 my $qty = get_onhand(part => $part); # how much is available?
448 my $qty = get_onhand(part => $part, date => $date); # how much was available at a specific time?
449 my $qty = get_onhand(part => $part, bin => $bin); # how much is available in a specific bin?
450 my $qty = get_onhand(part => $part, warehouse => $warehouse); # how much is available in a specific warehouse?
451 my $qty = get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber?
452 my $qty = get_onhand(part => $part, reserve_for => $order); # how much is available if you include this reservation?
455 my $data = get_onhand(
456 warehouse => $warehouse,
457 by => [ qw(bin part chargenumber reserve_for) ],
458 with_objects => [ qw(bin part) ],
462 my @allocations, allocate(
463 part => $part, # part_id works too
464 qty => $qty, # must be positive
465 chargenumber => $chargenumber, # optional, may be arrayref. if provided these charges will be used first
466 bestbefore => $datetime, # optional, defaults to today. items with bestbefore prior to that date wont be used
467 reserve_for => $object, # optional, may be arrayref. if provided the qtys reserved for these objects will be used first
468 bin => $bin, # optional, may be arrayref. if provided
471 # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate
472 my @allocations, allocate_for_assembly(
473 part => $assembly, # part_id works too
474 qty => $qty, # must be positive
477 # create allocation manually, bypassing checks, all of these need to be passed, even undefs
478 my $allocation = SL::Helper::Inventory::Allocation->new(
479 part_id => $part->id,
481 bin_id => $bin_obj->id,
482 warehouse_id => $bin_obj->warehouse_id,
483 chargenumber => '1823772365',
485 reserve_for_id => undef,
486 reserve_for_table => undef,
487 for_object_id => $order->id,
492 part => $part, # target assembly
494 allocations => \@allocations, # allocations to use. alternatively use "auto_allocate => 1,"
497 bin => $bin, # needed unless a global standard target is configured
498 chargenumber => $chargenumber, # optional
499 bestbefore => $datetime, # optional
500 comment => $comment, # optional
502 # links, all optional
503 production_order_item => $item,
504 reserve_for => $object,
509 New functions for the warehouse and inventory api.
511 The WH api currently has three large shortcomings. It is very hard to just get
512 the current stock for an item, it's extremely complicated to use it to produce
513 assemblies while ensuring that no stock ends up negative, and it's very hard to
514 use it to get an overview over the actual contents of the inventory.
516 The first problem has spawned several dozen small functions in the program that
517 try to implement that, and those usually miss some details. They may ignore
518 reservations, or reserve warehouses, or bestbefore times.
520 To get this cleaned up a bit this code introduces two concepts: stock and onhand.
522 Stock is defined as the actual contents of the inventory, everything that is
523 there. Onhand is what is available, which means things that are stocked and not
524 reserved and not expired.
526 The two new functions C<get_stock> and C<get_onhand> encapsulate these principles and
527 allow simple access with some optional filters for chargenumbers or warehouses.
528 Both of them have a batch mode that can be used to get these information to
529 supllement smiple reports.
531 To address the safe assembly creation a new function has been added.
532 C<allocate> will try to find the requested quantity of a part in the inventory
533 and will return allocations of it which can then be used to create the
534 assembly. Allocation will happen with the C<onhand> semantics defined above,
535 meaning that by default no reservations or expired goods will be used. The
536 caller can supply hints of what shold be used and in those cases chargenumber
537 and reservations will be used up as much as possible first. C<allocate> will
538 always try to fulfil the request even beyond those. Should the required amount
539 not be stocked, allocate will throw an exception.
541 C<produce_assembly> has been rewritten to only accept parameters about the
542 target of the production, and requires allocations to complete the request. The
543 allocations can be supplied manually, or can be generated automatically.
544 C<produce_assembly> will check whether enough allocations are given to create
545 the recipe, but will not check whether the allocations are backed. If the
546 allocations are not sufficient or if the auto-allocation fails an exception
547 is returned. If you need to produce something that is not in the inventory, you
548 can bypass those checks by creating the allocations yourself (see
549 L</"ALLOCATION DATA STRUCTURE">).
551 Note: this is only intended to cover the scenarios described above. For other cases:
557 If you need the reserved amount for an order use C<SL::DB::Helper::Reservation>
562 If you need actual inventory objects because of record links, prod_id links or
563 something like that load them directly. And strongly consider redesigning that,
564 because it's really fragile.
568 You need weight or accounting information you're on your own. The inventory api
569 only concerns itself with the raw quantities.
573 If you need the first stock date of parts, or anything related to a specific
574 transfer type or direction, this is not covered yet.
582 =item * get_stock PARAMS
584 Returns for single parts how much actually exists in the inventory.
592 The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
596 If given, will only return stock on these bins. Optional. May be array, May be object or id.
600 If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
604 If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
608 If given, will only show stock with this chargenumber. Optional. May be array.
612 See L</"STOCK/ONHAND REPORT MODE">
616 See L</"STOCK/ONHAND REPORT MODE">
620 Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
621 mode when C<by> is given.
623 =item * get_onhand PARAMS
625 Returns for single parts how much is available in the inventory. That excludes:
626 reserved quantities, reserved warehouses and stock with expired bestbefore.
628 It takes all options of L</get_stock> but treats some of the differently and has some additional ones:
634 Usually C<onhand> will not include results from warehouses with the C<reserve>
635 flag. However giving an explicit list of warehouses will include there in the
636 search, as well as all others.
640 =item * reserve_warehouse
646 =item * allocate PARAMS
658 Bin object. Optional.
662 Warehouse object. Optional.
674 Needs to be a rose object, where id and table can be extracted. Optional.
678 Tries to allocate the required quantity using what is currently onhand. If
679 given any of C<bin>, C<warehouse>, C<chargenumber>, C<reserve_for>
682 =item * allocate_for_assembly PARAMS
684 Shortcut to allocate everything for an assembly. Takes the same arguments. Will
685 compute the required amount for each assembly part and allocate all of them.
687 =item * produce_assembly
692 =head1 STOCK/ONHAND REPORT MODE
694 If the special option C<by> is given with an arrayref, the result will instead
695 be an arrayref of partitioned stocks by those fields. Valid partitions are:
701 If this is given, part is optional in the parameters
715 Note: If you want to use the returned data to create allocations you I<need> to
716 enable all of these. To make this easier a special shortcut exists
718 In this mode, C<with_objects> can be used to load C<warehouse>, C<bin>,
719 C<parts>, and the C<reserve_for> objects in one go, just like with Rose. They
720 need to be present in C<by> before that though.
722 =head1 ALLOCATION ALGORITHM
724 When calling allocate, the current onhand (== available stock) of the item will
725 be used to decide which bins/chargenumbers/bestbefore can be used.
727 In general allocate will try to make the request happen, and will use the
728 provided charges up first, and then tap everything else. If you need to only
729 I<exactly> use the provided charges, you'll need to craft the allocations
730 yourself. See L</"ALLOCATION DATA STRUCTURE"> for that.
732 If C<reserve_for> is given, those will be used up first too.
734 If C<reserved_warehouse> is given, those will be used up second.
736 If C<chargenumber> is given, those will be used up next.
738 After that normal quantities will be used.
740 These are tiebreakers and expected to rarely matter in reality. If you need
741 finegrained control over which allocation is used, you may want to get the
742 onhands yourself and select the appropriate ones.
744 Only quantities with C<bestbefore> unset or after the given date will be
745 considered. If more than one charge is eligible, the earlier C<bestbefore>
748 Allocations do NOT have an internal memory and can't react to other allocations
749 of the same part earlier. Never double allocate the same part within a
752 =head1 ALLOCATION DATA STRUCTURE
754 Allocations are instances of the helper class C<SL::Helper::Inventory::Allocation>. They require
755 each of the following attributes to be set at creation time:
771 =item * reserve_for_id
773 =item * reserve_for_table
775 =item * for_object_id
777 If set the allocations will be marked as allocated for the given object.
778 If these allocations are later used to produce an assembly, the resulting
779 consuming transactions will be marked as belonging to the given object.
780 The object may be an order, productionorder or other objects
784 C<chargenumber>, C<bestbefore>, C<reserve_for_id>, C<reserve_for_table> and
785 C<for_object_id> may be C<undef> (but must still be present at creation time).
786 Instances are considered immutable.
791 # whitelist constraints
795 bin_id => \@allowed_bins,
796 chargenumber => \@allowed_chargenumbers,
803 # only allow chargenumbers with specific format
804 all { $_->chargenumber =~ /^ C \d{8} - \a{d2} $/x } @_
807 # and must be all reservations
808 all { $_->reserve_for_id } @_;
812 C<allocation> is "best effort" in nature. It will take the C<bin>,
813 C<chargenumber> etc hints from the parameters, but will try it's bvest to
814 fulfil the request anyway and only bail out if it is absolutely not possible.
816 Sometimes you need to restrict allocations though. For this you can pass
817 additional constraints to C<allocate>. A constraint serves as a whitelist.
818 Every allocation must fulfil every constraint by having that attribute be one
821 In case even that is not enough, you may supply a custom check by passing a
822 function that will be given the allocation objects.
824 Note that both whitelists and constraints do not influence the order of
825 allocations, which is done purely from the initial parameters. They only serve
826 to reject allocations made in good faith which do fulfil required assertions.
828 =head1 ERROR HANDLING
830 C<allocate> and C<produce_assembly> will throw exceptions if the request can
831 not be completed. The usual reason will be insufficient onhand to allocate, or
832 insufficient allocations to process the request.
836 * define and describe error classes
837 * define wrapper classes for stock/onhand batch mode return values
838 * handle extra arguments in produce: shippingdate, project, oe
839 * clean up allocation helper class
840 * with objects for reservations
850 Sven Schöling E<lt>sven.schoeling@opendynamic.deE<gt>