From: Sven Schöling Date: Fri, 5 Jul 2019 14:08:02 +0000 (+0200) Subject: Inventory Helper X-Git-Tag: kivitendo-mebil_0.1-0~9^2~618 X-Git-Url: http://wagnertech.de/git?a=commitdiff_plain;h=9687d2ce94190d858260e10dea0a882b77d3a9b6;p=kivitendo-erp.git Inventory Helper --- diff --git a/SL/Helper/Inventory.pm b/SL/Helper/Inventory.pm new file mode 100644 index 000000000..f38faaace --- /dev/null +++ b/SL/Helper/Inventory.pm @@ -0,0 +1,738 @@ +package SL::Helper::Inventory; + +use strict; +use Carp; +use DateTime; +use Exporter qw(import); +use List::Util qw(min); +use List::UtilsBy qw(sort_by); +use List::MoreUtils qw(any); + +use SL::Locale::String qw(t8); +use SL::MoreCommon qw(listify); +use SL::DBUtils qw(selectall_hashref_query selectrow_query); +use SL::DB::TransferType; +use SL::X; + +our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly); +our %EXPORT_TAGS = (ALL => \@EXPORT_OK); + +sub _get_stock_onhand { + my (%params) = @_; + + my $onhand_mode = !!$params{onhand}; + + my @selects = ('SUM(qty) as qty'); + my @values; + my @where; + my @groups; + + if ($params{part}) { + my @ids = map { ref $_ ? $_->id : $_ } listify($params{part}); + push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids; + push @values, @ids; + } + + if ($params{bin}) { + my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin}); + push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids; + push @values, @ids; + } + + if ($params{warehouse}) { + my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse}); + push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids; + push @values, @ids; + } + + if ($params{chargenumber}) { + my @ids = listify($params{chargenumber}); + push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids; + push @values, @ids; + } + + if ($params{date}) { + push @where, sprintf "shippingdate <= ?"; + push @values, $params{date}; + } + + if ($params{bestbefore}) { + push @where, sprintf "bestbefore >= ?"; + push @values, $params{bestbefore}; + } + + # reserve_warehouse + if ($params{onhand} && !$params{warehouse}) { + push @where, 'NOT warehouse.forreserve'; + } + + # reserve_for + if ($params{onhand} && !$params{reserve_for}) { + push @where, 'reserve_for_id IS NULL AND reserve_for_table IS NULL'; + } + + if ($params{reserve_for}) { + my @objects = listify($params{chargenumber}); + my @tokens; + push @tokens, ( "(reserve_for_id = ? AND reserve_for_table = ?)") x @objects; + push @values, map { ($_->id, $_->meta->table) } @objects; + push @where, '(' . join(' OR ', @tokens) . ')'; + } + + # by + my %allowed_by = ( + part => [ qw(parts_id) ], + bin => [ qw(bin_id inventory.warehouse_id warehouse.forreserve)], + warehouse => [ qw(inventory.warehouse_id warehouse.forreserve) ], + chargenumber => [ qw(chargenumber) ], + bestbefore => [ qw(bestbefore) ], + reserve_for => [ qw(reserve_for_id reserve_for_table) ], + for_allocate => [ qw(parts_id bin_id inventory.warehouse_id warehouse.forreserve chargenumber bestbefore reserve_for_id reserve_for_table) ], + ); + + if ($params{by}) { + for (listify($params{by})) { + my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_"); + push @selects, @$selects; + push @groups, @$selects; + } + } + + my $select = join ',', @selects; + my $where = @where ? 'WHERE ' . join ' AND ', @where : ''; + my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : ''; + + my $query = <<""; + SELECT $select FROM inventory + LEFT JOIN bin ON bin_id = bin.id + LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id + $where + $group_by + HAVING SUM(qty) > 0 + + my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values); + + my %with_objects = ( + part => 'SL::DB::Manager::Part', + bin => 'SL::DB::Manager::Bin', + warehouse => 'SL::DB::Manager::Warehouse', + reserve_for => undef, + ); + + my %slots = ( + part => 'parts_id', + bin => 'bin_id', + warehouse => 'warehouse_id', + ); + + if ($params{by} && $params{with_objects}) { + for my $with_object (listify($params{with_objects})) { + Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object}; + + if (my $manager = $with_objects{$with_object}) { + my $slot = $slots{$with_object}; + next if !(my @ids = map { $_->{$slot} } @$results); + my $objects = $manager->get_all(query => [ id => \@ids ]); + my %objects_by_id = map { $_->id => $_ } @$objects; + + $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results; + } else { + # need to fetch all reserve_for_table partitions + } + } + } + + if ($params{by}) { + return $results; + } else { + return $results->[0]{qty}; + } +} + +sub get_stock { + _get_stock_onhand(@_, onhand => 0); +} + +sub get_onhand { + _get_stock_onhand(@_, onhand => 1); +} + +sub allocate { + my (%params) = @_; + + my $part = $params{part} or Carp::croak('allocate needs a part'); + my $qty = $params{qty} or Carp::croak('allocate needs a qty'); + + return () if $qty <= 0; + + my $results = get_stock(part => $part, by => 'for_allocate'); + my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($params{bin}); + my %wh_whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($params{warehouse}); + my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } listify($params{chargenumber}); + my %reserve_whitelist; + if ($params{reserve_for}) { + $reserve_whitelist{ $_->meta->table }{ $_->id } = 1 for listify($params{reserve_for}); + } + + # filter the results. we don't want: + # - negative amounts + # - bins that are reserve but not in the white-list of warehouses or bins + # - reservations that are not white-listed + + my @filtered_results = grep { + (!$_->{forreserve} || $bin_whitelist{$_->{bin_id}} || $wh_whitelist{$_->{warehouse_id}}) + && (!$_->{reserve_for_id} || $reserve_whitelist{ $_->{reserve_for_table} }{ $_->{reserve_for_id} }) + } @$results; + + # sort results so that reserve_for is first, then chargenumbers, then wanted bins, then wanted warehouses + my @sorted_results = sort { + (!!$b->{reserve_for_id}) <=> (!!$a->{reserve_for_id}) # sort by existing reserve_for_id first. + || $chargenumbers{$b->{chargenumber}} <=> $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers + || $bin_whitelist{$b->{bin_id}} <=> $bin_whitelist{$a->{bin_id}} # then prefer wanted bins + || $wh_whitelist{$b->{warehouse_id}} <=> $wh_whitelist{$a->{warehouse_id}} # then prefer wanted bins + } @filtered_results; + + my @allocations; + my $rest_qty = $qty; + + for my $chunk (@sorted_results) { + my $qty = min($chunk->{qty}, $rest_qty); + if ($qty > 0) { + push @allocations, SL::Helper::Inventory::Allocation->new( + parts_id => $chunk->{parts_id}, + qty => $qty, + comment => $params{comment}, + bin_id => $chunk->{bin_id}, + warehouse_id => $chunk->{warehouse_id}, + chargenumber => $chunk->{chargenumber}, + bestbefore => $chunk->{bestbefore}, + reserve_for_id => $chunk->{reserve_for_id}, + reserve_for_table => $chunk->{reserve_for_table}, + ); + $rest_qty -= $qty; + } + + last if $rest_qty == 0; + } + + if ($rest_qty > 0) { + die SL::X::Inventory::Allocation->new( + error => 'not enough to allocate', + msg => t8("can not allocate #1 units of #2, missing #3 units", $qty, $part->displayable_name, $rest_qty), + ); + } else { + return @allocations; + } +} + +sub allocate_for_assembly { + my (%params) = @_; + + my $part = $params{part} or Carp::croak('allocate needs a part'); + my $qty = $params{qty} or Carp::croak('allocate needs a qty'); + + Carp::croak('not an assembly') unless $part->is_assembly; + + my %parts_to_allocate; + + for my $assembly ($part->assemblies) { + $parts_to_allocate{ $assembly->part->id } //= 0; + $parts_to_allocate{ $assembly->part->id } += $assembly->qty * $qty; # TODO recipe factor + } + + my @allocations; + + for my $part_id (keys %parts_to_allocate) { + my $part = SL::DB::Part->new(id => $part_id); + push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id}); + } + + @allocations; +} + +sub produce_assembly { + my (%params) = @_; + + my $part = $params{part} or Carp::croak('allocate needs a part'); + my $qty = $params{qty} or Carp::croak('allocate needs a qty'); + + my $allocations = $params{allocations}; + if (!$allocations && $params{auto_allocate}) { + $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ]; + } else { + Carp::croak("need allocations or auto_allocate to produce something") unless $allocations; + } + + my $bin = $params{bin} or Carp::croak("need target bin"); + my $chargenumber = $params{chargenumber}; + my $bestbefore = $params{bestbefore}; + my $comment = $params{comment} // ''; + + my $production_order_item = $params{production_order_item}; + my $invoice = $params{invoice}; + my $project = $params{project}; + my $reserve_for = $params{reserve_for}; + + my $reserve_for_id = $reserve_for ? $reserve_for->id : undef; + my $reserve_for_table = $reserve_for ? $reserve_for->meta->table : undef; + + my $shippingdate = $params{shippingsdate} // DateTime->now_local; + + my $trans_id = $params{trans_id}; + ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id; + + my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used'); + my $trans_type_in = SL::DB::Manager::TransferType->find_by(direction => 'in', description => 'assembled'); + + # check whether allocations are sane + if (!$params{no_check_allocations} && !$params{auto_allocate}) { + my %allocations_by_part = map { $_->parts_id => $_->qty } @$allocations; + for my $assembly ($part->assemblies) { + $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty; # TODO recipe factor + } + + die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part; + } + + my @transfers; + for my $allocation (@$allocations) { + push @transfers, SL::DB::Inventory->new( + trans_id => $trans_id, + %$allocation, + qty => -$allocation->qty, + trans_type => $trans_type_out, + shippingdate => $shippingdate, + employee => SL::DB::Manager::Employee->current, + ); + } + + push @transfers, SL::DB::Inventory->new( + trans_id => $trans_id, + trans_type => $trans_type_in, + part => $part, + qty => $qty, + bin => $bin, + warehouse => $bin->warehouse_id, + chargenumber => $chargenumber, + bestbefore => $bestbefore, + reserve_for_id => $reserve_for_id, + reserve_for_table => $reserve_for_table, + shippingdate => $shippingdate, + project => $project, + invoice => $invoice, + comment => $comment, + prod => $production_order_item, + employee => SL::DB::Manager::Employee->current, + ); + + SL::DB->client->with_transaction(sub { + $_->save for @transfers; + 1; + }) or do { + die SL::DB->client->error; + }; + + @transfers; +} + +package SL::Helper::Inventory::Allocation { + my @attributes = qw(parts_id qty bin_id warehouse_id chargenumber bestbefore comment reserve_for_id reserve_for_table); + my %attributes = map { $_ => 1 } @attributes; + + for my $name (@attributes) { + no strict 'refs'; + *{"WH::Allocation::$name"} = sub { $_[0]{$name} }; + } + + sub new { + my ($class, %params) = @_; + + Carp::croak("missing attribute $_") for grep { !exists $params{$_} } @attributes; + Carp::croak("unknown attribute $_") for grep { !exists $attributes{$_} } keys %params; + Carp::croak("$_ must be set") for grep { !$params{$_} } qw(parts_id qty bin_id); + Carp::croak("$_ must be positive") for grep { !($params{$_} > 0) } qw(parts_id qty bin_id); + + bless { %params }, $class; + } +} + +1; + +=encoding utf-8 + +=head1 NAME + +SL::WH - Warehouse and Inventory API + +=head1 SYNOPSIS + + # See description for an intro to the concepts used here. + + use SL::Helper::Inventory; + + # stock, get "what's there" for a part with various conditions: + my $qty = SL::Helper::Inventory->get_stock(part => $part); # how much is on stock? + my $qty = SL::Helper::Inventory->get_stock(part => $part, date => $date); # how much was on stock at a specific time? + my $qty = SL::Helper::Inventory->get_stock(part => $part, bin => $bin); # how is on stock in a specific bin? + my $qty = SL::Helper::Inventory->get_stock(part => $part, warehouse => $warehouse); # how is on stock in a specific warehouse? + my $qty = SL::Helper::Inventory->get_stock(part => $part, chargenumber => $chargenumber); # how is on stock of a specific chargenumber? + + # onhand, get "what's available" for a part with various conditions: + my $qty = SL::Helper::Inventory->get_onhand(part => $part); # how much is available? + my $qty = SL::Helper::Inventory->get_onhand(part => $part, date => $date); # how much was available at a specific time? + my $qty = SL::Helper::Inventory->get_onhand(part => $part, bin => $bin); # how much is available in a specific bin? + my $qty = SL::Helper::Inventory->get_onhand(part => $part, warehouse => $warehouse); # how much is available in a specific warehouse? + my $qty = SL::Helper::Inventory->get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber? + my $qty = SL::Helper::Inventory->get_onhand(part => $part, reserve_for => $order); # how much is available if you include this reservation? + + # onhand batch mode: + my $data = SL::Helper::Inventory->get_onhand( + warehouse => $warehouse, + by => [ qw(bin part chargenumber reserve_for) ], + with_objects => [ qw(bin part) ], + ); + + # allocate: + my @allocations, SL::Helper::Inventory->allocate( + part => $part, # part_id works too + qty => $qty, # must be positive + chargenumber => $chargenumber, # optional, may be arrayref. if provided these charges will be used first + bestbefore => $datetime, # optional, defaults to today. items with bestbefore prior to that date wont be used + reserve_for => $object, # optional, may be arrayref. if provided the qtys reserved for these objects will be used first + bin => $bin, # optional, may be arrayref. if provided + ); + + # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate + my @allocations, SL::Helper::Inventory->allocate_for_assembly( + part => $assembly, # part_id works too + qty => $qty, # must be positive + ); + + # create allocation manually, bypassing checks, all of these need to be passed, even undefs + my $allocation = SL::Helper::Inventory::Allocation->new( + part_id => $part->id, + qty => 15, + bin_id => $bin_obj->id, + warehouse_id => $bin_obj->warehouse_id, + chargenumber => '1823772365', + bestbefore => undef, + reserve_for_id => undef, + reserve_for_table => undef, + ); + + # produce_assembly: + SL::Helper::Inventory->produce_assembly( + part => $part, # target assembly + qty => $qty, # qty + allocations => \@allocations, # allocations to use. alternatively use "auto_allocate => 1," + + # where to put it + bin => $bin, # needed unless a global standard target is configured + chargenumber => $chargenumber, # optional + bestbefore => $datetime, # optional + comment => $comment, # optional + + # links, all optional + production_order_item => $item, + reserve_for => $object, + ); + +=head1 DESCRIPTION + +New functions for the warehouse and inventory api. + +The WH api currently has three large shortcomings. It is very hard to just get +the current stock for an item, it's extremely complicated to use it to produce +assemblies while ensuring that no stock ends up negative, and it's very hard to +use it to get an overview over the actual contents of the inventory. + +The first problem has spawned several dozen small functions in the program that +try to implement that, and those usually miss some details. They may ignore +reservations, or reserve warehouses, or bestbefore times. + +To get this cleaned up a bit this code introduces two concepts: stock and onhand. + +Stock is defined as the actual contents of the inventory, everything that is +there. Onhand is what is available, which means things that are stocked and not +reserved and not expired. + +The two new functions C and C encapsulate these principles and +allow simple access with some optional filters for chargenumbers or warehouses. +Both of them have a batch mode that can be used to get these information to +supllement smiple reports. + +To address the safe assembly creation a new function has been added. +C will try to find the requested quantity of a part in the inventory +and will return allocations of it which can then be used to create the +assembly. Allocation will happen with the C semantics defined above, +meaning that by default no reservations or expired goods will be used. The +caller can supply hints of what shold be used and in those cases chargenumber +and reservations will be used up as much as possible first. C will +always try to fulfil the request even beyond those. Should the required amount +not be stocked, allocate will throw an exception. + +C has been rewritten to only accept parameters about the +target of the production, and requires allocations to complete the request. The +allocations can be supplied manually, or can be generated automatically. +C will check whether enough allocations are given to create +the recipe, but will not check whether the allocations are backed. If the +allocations are not sufficient or if the auto-allocation fails an exception +is returned. If you need to produce something that is not in the inventory, you +can bypass those checks by creating the allocations yourself (see +L). + +Note: this is only intended to cover the scenarios described above. For other cases: + +=over 4 + +=item * + +If you need the reserved amount for an order use C +instead. + +=item * + +If you need actual inventory objects because of record links, prod_id links or +something like that load them directly. And strongly consider redesigning that, +because it's really fragile. + +=item * + +You need weight or accounting information you're on your own. The inventory api +only concerns itself with the raw quantities. + +=item * + +If you need the first stock date of parts, or anything related to a specific +transfer type or direction, this is not covered yet. + +=back + +=head1 FUNCTIONS + +=over 4 + +=item * get_stock PARAMS + +Returns for single parts how much actually exists in the inventory. + +Options: + +=over 4 + +=item * part + +The part. Must be present without C. May be arrayref with C. Can be object or id. + +=item * bin + +If given, will only return stock on these bins. Optional. May be array, May be object or id. + +=item * warehouse + +If given, will only return stock on these warehouses. Optional. May be array, May be object or id. + +=item * date + +If given, will return stock as it were on this timestamp. Optional. Must be L object. + +=item * chargenumber + +If given, will only show stock with this chargenumber. Optional. May be array. + +=item * by + +See L + +=item * with_objects + +See L + +=back + +Will return a single qty normally, see L for batch +mode when C is given. + +=item * get_onhand PARAMS + +Returns for single parts how much is available in the inventory. That excludes: +reserved quantities, reserved warehouses and stock with expired bestbefore. + +It takes all options of L but treats some of the differently and has some additional ones: + +=over 4 + +=item * warehouse + +Usually C will not include results from warehouses with the C +flag. However giving an explicit list of warehouses will include there in the +search, as well as all others. + +=item * reserve_for + +=item * reserve_warehouse + +=item * bestbefore + +=back + +=item * allocate PARAMS + +Accepted parameters: + +=over 4 + +=item * part + +=item * qty + +=item * bin + +Bin object. Optional. + +=item * warehouse + +Warehouse object. Optional. + +=item * chargenumber + +Optional. + +=item * bestbefore + +Datetime. Optional. + +=item * reserve_for + +Needs to be a rose object, where id and table can be extracted. Optional. + +=back + +Tries to allocate the required quantity using what is currently onhand. If +given any of C, C, C, C + + +=item * allocate_for_assembly PARAMS + +Shortcut to allocate everything for an assembly. Takes the same arguments. Will +compute the required amount for each assembly part and allocate all of them. + +=item * produce_assembly + + +=back + +=head1 STOCK/ONHAND REPORT MODE + +If the special option C is given with an arrayref, the result will instead +be an arrayref of partitioned stocks by those fields. Valid partitions are: + +=over 4 + +=item * part + +If this is given, part is optional in the parameters + +=item * bin + +=item * warehouse + +=item * chargenumber + +=item * bestbefore + +=item * reserve_for + +=back + +Note: If you want to use the returned data to create allocations you I to +enable all of these. To make this easier a special shortcut exists + +In this mode, C can be used to load C, C, +C, and the C objects in one go, just like with Rose. They +need to be present in C before that though. + +=head1 ALLOCATION ALGORITHM + +When calling allocate, the current onhand (== available stock) of the item will +be used to decide which bins/chargenumbers/bestbefore can be used. + +In general allocate will try to make the request happen, and will use the +provided charges up first, and then tap everything else. If you need to only +I use the provided charges, you'll need to craft the allocations +yourself. See L for that. + +If C is given, those will be used up first too. + +If C is given, those will be used up second. + +If C is given, those will be used up next. + +After that normal quantities will be used. + +These are tiebreakers and expected to rarely matter in reality. If you need +finegrained control over which allocation is used, you may want to get the +onhands yourself and select the appropriate ones. + +Only quantities with C unset or after the given date will be +considered. If more than one charge is eligible, the earlier C +will be used. + +Allocations do NOT have an internal memory and can't react to other allocations +of the same part earlier. Never double allocate the same part within a +transaction. + +=head1 ALLOCATION DATA STRUCTURE + +Allocations are instances of the helper class C. They require +each of the following attributes to be set at creation time: + +=over 4 + +=item * parts_id + +=item * qty + +=item * bin_id + +=item * warehouse_id + +=item * chargenumber + +=item * bestbefore + +=item * reserve_for_id + +=item * reserve_for_table + +=back + +C, C, C and C may +be C (but must still be present at creation time). Instances are +considered immutable. + +=head1 ERROR HANDLING + +C and C will throw exceptions if the request can +not be completed. The usual reason will be insufficient onhand to allocate, or +insufficient allocations to process the request. + +=head1 TODO + + * define and describe error classes + * define wrapper classes for stock/onhand batch mode return values + * handle extra arguments in produce: shippingdate, project, oe + * clean up allocation helper class + * with objects for reservations + * document no_ check + * tests + +=head1 BUGS + +None yet :) + +=head1 AUTHOR + +Sven Schöling Esven.schoeling@opendynamic.deE + +=cut diff --git a/SL/X.pm b/SL/X.pm index 9343deab0..4acd3bbab 100644 --- a/SL/X.pm +++ b/SL/X.pm @@ -30,6 +30,16 @@ use Exception::Class ( 'SL::X::ZUGFeRDValidation' => { isa => 'SL::X::Base', }, + 'SL::X::Inventory' => { + isa => 'SL::X::Base', + fields => [ qw(msg error) ], + defaults => { error_template => [ '%s: %s', qw(msg error) ] }, + }, + 'SL::X::Inventory::Allocation' => { + isa => 'SL::X::Base', + fields => [ qw(msg error) ], + defaults => { error_template => [ '%s: %s', qw(msg error) ] }, + }, ); 1; diff --git a/t/wh/inventory.t b/t/wh/inventory.t new file mode 100644 index 000000000..1a6306d2c --- /dev/null +++ b/t/wh/inventory.t @@ -0,0 +1,126 @@ +use strict; +use Test::More; + +use lib 't'; + +use SL::Dev::Part qw(new_part new_assembly); +use SL::Dev::Inventory qw(create_warehouse_and_bins set_stock); +use SL::Dev::Record qw(create_sales_order); +use SL::DB::Helper::Reservation qw(make_reservation); + +use_ok 'Support::TestSetup'; +use_ok 'SL::DB::Bin'; +use_ok 'SL::DB::Part'; +use_ok 'SL::DB::Warehouse'; +use_ok 'SL::DB::Inventory'; +use_ok 'SL::WH'; +use_ok 'SL::Helper::Inventory'; + +Support::TestSetup::login(); + +my ($wh, $bin1, $bin2, $assembly1); + +reset_db(); +create_standard_stock(); + + +# simple stock in, get_stock, get_onhand +set_stock( + part => $assembly1, + qty => 25, + bin => $bin1, +); + +is(SL::Helper::Inventory::get_stock(part => $assembly1), "25.00000", 'simple get_stock works'); +is(SL::Helper::Inventory::get_onhand(part => $assembly1), "25.00000", 'simple get_onhand works'); + +# stock on some more, get_stock, get_onhand + +WH->transfer({ + parts_id => $assembly1->id, + qty => 15, + transfer_type => 'stock', + dst_warehouse_id => $bin1->warehouse_id, + dst_bin_id => $bin1->id, + comment => 'more', +}); + +WH->transfer({ + parts_id => $assembly1->id, + qty => 20, + transfer_type => 'stock', + chargenumber => '298345', + dst_warehouse_id => $bin1->warehouse_id, + dst_bin_id => $bin1->id, + comment => 'more', +}); + +is(SL::Helper::Inventory::get_stock(part => $assembly1), "60.00000", 'normal get_stock works'); +is(SL::Helper::Inventory::get_onhand(part => $assembly1), "60.00000", 'normal get_onhand works'); + +# reserve some of it, get_stock, get_onhand + +my $order = create_sales_order(save => 1); + +make_reservation( + part => $assembly1, + bin => $bin1, + reserve_for => $order, + qty => 25, +); + +is(WH->get_stock_(part => $assembly1), "60.00000", 'normal get_stock works'); +is(WH->get_onhand_(part => $assembly1), "35.00000", 'normal get_onhand works'); + +# allocate some stuff + +my @allocations = SL::Helper::Inventory::allocate( + part => $assembly1, + qty => 12, +); + +is_deeply(\%{ $allocations[0] }, { + bestbefore => undef, + bin_id => $bin1->id, + chargenumber => '', + parts_id => $assembly1->id, + qty => 12, + reserve_for_id => undef, + reserve_for_table => undef, + warehouse_id => $wh->id, + }, 'allocatiion works'); + +# simple + +# with reservation + +# more than exists + +# produce something + +# produce the same using auto_allocation + + +sub reset_db { + SL::DB::Manager::Order->delete_all(all => 1); + SL::DB::Manager::Inventory->delete_all(all => 1); + SL::DB::Manager::Assembly->delete_all(all => 1); + SL::DB::Manager::Part->delete_all(all => 1); + SL::DB::Manager::Bin->delete_all(all => 1); + SL::DB::Manager::Warehouse->delete_all(all => 1); +} + +sub create_standard_stock { + ($wh, $bin1) = create_warehouse_and_bins(); + $bin2 = SL::DB::Bin->new(description => "Bin 2", warehouse => $wh)->save; + $wh->load; + + $assembly1 = new_assembly()->save; +} + + +reset(); + +done_testing(); + +1;