1 package SL::Helper::Inventory;
 
   6 use Exporter qw(import);
 
   7 use List::Util qw(min);
 
   8 use List::UtilsBy qw(sort_by);
 
   9 use List::MoreUtils qw(any);
 
  11 use SL::Locale::String qw(t8);
 
  12 use SL::MoreCommon qw(listify);
 
  13 use SL::DBUtils qw(selectall_hashref_query selectrow_query);
 
  14 use SL::DB::TransferType;
 
  17 our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly);
 
  18 our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
 
  20 sub _get_stock_onhand {
 
  23   my $onhand_mode = !!$params{onhand};
 
  25   my @selects = ('SUM(qty) as qty');
 
  31     my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
 
  32     push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
 
  37     my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
 
  38     push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
 
  42   if ($params{warehouse}) {
 
  43     my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
 
  44     push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
 
  48   if ($params{chargenumber}) {
 
  49     my @ids = listify($params{chargenumber});
 
  50     push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
 
  55     push @where, sprintf "shippingdate <= ?";
 
  56     push @values, $params{date};
 
  59   if ($params{bestbefore}) {
 
  60     push @where, sprintf "bestbefore >= ?";
 
  61     push @values, $params{bestbefore};
 
  65   if ($params{onhand} && !$params{warehouse}) {
 
  66     push @where, 'NOT warehouse.forreserve';
 
  70   if ($params{onhand} && !$params{reserve_for}) {
 
  71     push @where, 'reserve_for_id IS NULL AND reserve_for_table IS NULL';
 
  74   if ($params{reserve_for}) {
 
  75     my @objects = listify($params{chargenumber});
 
  77     push @tokens, ( "(reserve_for_id = ? AND reserve_for_table = ?)") x @objects;
 
  78     push @values, map { ($_->id, $_->meta->table) } @objects;
 
  79     push @where, '(' . join(' OR ', @tokens) . ')';
 
  84     part          => [ qw(parts_id) ],
 
  85     bin           => [ qw(bin_id inventory.warehouse_id warehouse.forreserve)],
 
  86     warehouse     => [ qw(inventory.warehouse_id warehouse.forreserve) ],
 
  87     chargenumber  => [ qw(chargenumber) ],
 
  88     bestbefore    => [ qw(bestbefore) ],
 
  89     reserve_for   => [ qw(reserve_for_id reserve_for_table) ],
 
  90     for_allocate  => [ qw(parts_id bin_id inventory.warehouse_id warehouse.forreserve chargenumber bestbefore reserve_for_id reserve_for_table) ],
 
  94     for (listify($params{by})) {
 
  95       my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_");
 
  96       push @selects, @$selects;
 
  97       push @groups,  @$selects;
 
 101   my $select   = join ',', @selects;
 
 102   my $where    = @where  ? 'WHERE ' . join ' AND ', @where : '';
 
 103   my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
 
 106     SELECT $select FROM inventory
 
 107     LEFT JOIN bin ON bin_id = bin.id
 
 108     LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
 
 113   my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
 
 116     part         => 'SL::DB::Manager::Part',
 
 117     bin          => 'SL::DB::Manager::Bin',
 
 118     warehouse    => 'SL::DB::Manager::Warehouse',
 
 119     reserve_for  => undef,
 
 125     warehouse =>  'warehouse_id',
 
 128   if ($params{by} && $params{with_objects}) {
 
 129     for my $with_object (listify($params{with_objects})) {
 
 130       Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object};
 
 132       if (my $manager = $with_objects{$with_object}) {
 
 133         my $slot = $slots{$with_object};
 
 134         next if !(my @ids = map { $_->{$slot} } @$results);
 
 135         my $objects = $manager->get_all(query => [ id => \@ids ]);
 
 136         my %objects_by_id = map { $_->id => $_ } @$objects;
 
 138         $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
 
 140         # need to fetch all reserve_for_table partitions
 
 148     return $results->[0]{qty};
 
 153   _get_stock_onhand(@_, onhand => 0);
 
 157   _get_stock_onhand(@_, onhand => 1);
 
 163   my $part = $params{part} or Carp::croak('allocate needs a part');
 
 164   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
 
 166   return () if $qty <= 0;
 
 168   my $results = get_stock(part => $part, by => 'for_allocate');
 
 169   my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($params{bin});
 
 170   my %wh_whitelist  = map { (ref $_ ? $_->id : $_) => 1 } listify($params{warehouse});
 
 171   my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } listify($params{chargenumber});
 
 172   my %reserve_whitelist;
 
 173   if ($params{reserve_for}) {
 
 174     $reserve_whitelist{ $_->meta->table }{ $_->id } = 1 for listify($params{reserve_for});
 
 177   # filter the results. we don't want:
 
 179   # - bins that are reserve but not in the white-list of warehouses or bins
 
 180   # - reservations that are not white-listed
 
 182   my @filtered_results = grep {
 
 183        (!$_->{forreserve} || $bin_whitelist{$_->{bin_id}} || $wh_whitelist{$_->{warehouse_id}})
 
 184     && (!$_->{reserve_for_id} || $reserve_whitelist{ $_->{reserve_for_table} }{ $_->{reserve_for_id} })
 
 187   # sort results so that reserve_for is first, then chargenumbers, then wanted bins, then wanted warehouses
 
 188   my @sorted_results = sort {
 
 189        (!!$b->{reserve_for_id})    <=> (!!$a->{reserve_for_id})                   # sort by existing reserve_for_id first.
 
 190     || $chargenumbers{$b->{chargenumber}}  <=> $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers
 
 191     || $bin_whitelist{$b->{bin_id}}        <=> $bin_whitelist{$a->{bin_id}}       # then prefer wanted bins
 
 192     || $wh_whitelist{$b->{warehouse_id}}   <=> $wh_whitelist{$a->{warehouse_id}}  # then prefer wanted bins
 
 198   for my $chunk (@sorted_results) {
 
 199     my $qty = min($chunk->{qty}, $rest_qty);
 
 201       push @allocations, SL::Helper::Inventory::Allocation->new(
 
 202         parts_id          => $chunk->{parts_id},
 
 204         comment           => $params{comment},
 
 205         bin_id            => $chunk->{bin_id},
 
 206         warehouse_id      => $chunk->{warehouse_id},
 
 207         chargenumber      => $chunk->{chargenumber},
 
 208         bestbefore        => $chunk->{bestbefore},
 
 209         reserve_for_id    => $chunk->{reserve_for_id},
 
 210         reserve_for_table => $chunk->{reserve_for_table},
 
 215     last if $rest_qty == 0;
 
 219     die SL::X::Inventory::Allocation->new(
 
 220       error => 'not enough to allocate',
 
 221       msg => t8("can not allocate #1 units of #2, missing #3 units", $qty, $part->displayable_name, $rest_qty),
 
 228 sub allocate_for_assembly {
 
 231   my $part = $params{part} or Carp::croak('allocate needs a part');
 
 232   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
 
 234   Carp::croak('not an assembly') unless $part->is_assembly;
 
 236   my %parts_to_allocate;
 
 238   for my $assembly ($part->assemblies) {
 
 239     $parts_to_allocate{ $assembly->part->id } //= 0;
 
 240     $parts_to_allocate{ $assembly->part->id } += $assembly->qty * $qty; # TODO recipe factor
 
 245   for my $part_id (keys %parts_to_allocate) {
 
 246     my $part = SL::DB::Part->new(id => $part_id);
 
 247     push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id});
 
 253 sub produce_assembly {
 
 256   my $part = $params{part} or Carp::croak('allocate needs a part');
 
 257   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
 
 259   my $allocations = $params{allocations};
 
 260   if (!$allocations && $params{auto_allocate}) {
 
 261     $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ];
 
 263     Carp::croak("need allocations or auto_allocate to produce something") unless $allocations;
 
 266   my $bin          = $params{bin} or Carp::croak("need target bin");
 
 267   my $chargenumber = $params{chargenumber};
 
 268   my $bestbefore   = $params{bestbefore};
 
 269   my $comment      = $params{comment} // '';
 
 271   my $production_order_item = $params{production_order_item};
 
 272   my $invoice               = $params{invoice};
 
 273   my $project               = $params{project};
 
 274   my $reserve_for           = $params{reserve_for};
 
 276   my $reserve_for_id    = $reserve_for ? $reserve_for->id          : undef;
 
 277   my $reserve_for_table = $reserve_for ? $reserve_for->meta->table : undef;
 
 279   my $shippingdate = $params{shippingsdate} // DateTime->now_local;
 
 281   my $trans_id              = $params{trans_id};
 
 282   ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
 
 284   my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used');
 
 285   my $trans_type_in  = SL::DB::Manager::TransferType->find_by(direction => 'in', description => 'assembled');
 
 287   # check whether allocations are sane
 
 288   if (!$params{no_check_allocations} && !$params{auto_allocate}) {
 
 289     my %allocations_by_part = map { $_->parts_id  => $_->qty } @$allocations;
 
 290     for my $assembly ($part->assemblies) {
 
 291       $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty; # TODO recipe factor
 
 294     die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part;
 
 298   for my $allocation (@$allocations) {
 
 299     push @transfers, SL::DB::Inventory->new(
 
 300       trans_id     => $trans_id,
 
 302       qty          => -$allocation->qty,
 
 303       trans_type   => $trans_type_out,
 
 304       shippingdate => $shippingdate,
 
 305       employee     => SL::DB::Manager::Employee->current,
 
 309   push @transfers, SL::DB::Inventory->new(
 
 310     trans_id          => $trans_id,
 
 311     trans_type        => $trans_type_in,
 
 315     warehouse         => $bin->warehouse_id,
 
 316     chargenumber      => $chargenumber,
 
 317     bestbefore        => $bestbefore,
 
 318     reserve_for_id    => $reserve_for_id,
 
 319     reserve_for_table => $reserve_for_table,
 
 320     shippingdate      => $shippingdate,
 
 324     prod              => $production_order_item,
 
 325     employee          => SL::DB::Manager::Employee->current,
 
 328   SL::DB->client->with_transaction(sub {
 
 329     $_->save for @transfers;
 
 332     die SL::DB->client->error;
 
 338 package SL::Helper::Inventory::Allocation {
 
 339   my @attributes = qw(parts_id qty bin_id warehouse_id chargenumber bestbefore comment reserve_for_id reserve_for_table);
 
 340   my %attributes = map { $_ => 1 } @attributes;
 
 342   for my $name (@attributes) {
 
 344     *{"WH::Allocation::$name"} = sub { $_[0]{$name} };
 
 348     my ($class, %params) = @_;
 
 350     Carp::croak("missing attribute $_") for grep { !exists $params{$_}     } @attributes;
 
 351     Carp::croak("unknown attribute $_") for grep { !exists $attributes{$_} } keys %params;
 
 352     Carp::croak("$_ must be set")       for grep { !$params{$_} } qw(parts_id qty bin_id);
 
 353     Carp::croak("$_ must be positive")  for grep { !($params{$_} > 0) } qw(parts_id qty bin_id);
 
 355     bless { %params }, $class;
 
 365 SL::WH - Warehouse and Inventory API
 
 369   # See description for an intro to the concepts used here.
 
 371   use SL::Helper::Inventory;
 
 373   # stock, get "what's there" for a part with various conditions:
 
 374   my $qty = SL::Helper::Inventory->get_stock(part => $part);                              # how much is on stock?
 
 375   my $qty = SL::Helper::Inventory->get_stock(part => $part, date => $date);               # how much was on stock at a specific time?
 
 376   my $qty = SL::Helper::Inventory->get_stock(part => $part, bin => $bin);                 # how is on stock in a specific bin?
 
 377   my $qty = SL::Helper::Inventory->get_stock(part => $part, warehouse => $warehouse);     # how is on stock in a specific warehouse?
 
 378   my $qty = SL::Helper::Inventory->get_stock(part => $part, chargenumber => $chargenumber); # how is on stock of a specific chargenumber?
 
 380   # onhand, get "what's available" for a part with various conditions:
 
 381   my $qty = SL::Helper::Inventory->get_onhand(part => $part);                              # how much is available?
 
 382   my $qty = SL::Helper::Inventory->get_onhand(part => $part, date => $date);               # how much was available at a specific time?
 
 383   my $qty = SL::Helper::Inventory->get_onhand(part => $part, bin => $bin);                 # how much is available in a specific bin?
 
 384   my $qty = SL::Helper::Inventory->get_onhand(part => $part, warehouse => $warehouse);     # how much is available in a specific warehouse?
 
 385   my $qty = SL::Helper::Inventory->get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber?
 
 386   my $qty = SL::Helper::Inventory->get_onhand(part => $part, reserve_for => $order);       # how much is available if you include this reservation?
 
 389   my $data = SL::Helper::Inventory->get_onhand(
 
 390     warehouse    => $warehouse,
 
 391     by           => [ qw(bin part chargenumber reserve_for) ],
 
 392     with_objects => [ qw(bin part) ],
 
 396   my @allocations, SL::Helper::Inventory->allocate(
 
 397     part         => $part,          # part_id works too
 
 398     qty          => $qty,           # must be positive
 
 399     chargenumber => $chargenumber,  # optional, may be arrayref. if provided these charges will be used first
 
 400     bestbefore   => $datetime,      # optional, defaults to today. items with bestbefore prior to that date wont be used
 
 401     reserve_for  => $object,        # optional, may be arrayref. if provided the qtys reserved for these objects will be used first
 
 402     bin          => $bin,           # optional, may be arrayref. if provided
 
 405   # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate
 
 406   my @allocations, SL::Helper::Inventory->allocate_for_assembly(
 
 407     part         => $assembly,      # part_id works too
 
 408     qty          => $qty,           # must be positive
 
 411   # create allocation manually, bypassing checks, all of these need to be passed, even undefs
 
 412   my $allocation = SL::Helper::Inventory::Allocation->new(
 
 413     part_id           => $part->id,
 
 415     bin_id            => $bin_obj->id,
 
 416     warehouse_id      => $bin_obj->warehouse_id,
 
 417     chargenumber      => '1823772365',
 
 419     reserve_for_id    => undef,
 
 420     reserve_for_table => undef,
 
 424   SL::Helper::Inventory->produce_assembly(
 
 425     part         => $part,           # target assembly
 
 427     allocations  => \@allocations,   # allocations to use. alternatively use "auto_allocate => 1,"
 
 430     bin          => $bin,           # needed unless a global standard target is configured
 
 431     chargenumber => $chargenumber,  # optional
 
 432     bestbefore   => $datetime,      # optional
 
 433     comment      => $comment,       # optional
 
 435     # links, all optional
 
 436     production_order_item => $item,
 
 437     reserve_for           => $object,
 
 442 New functions for the warehouse and inventory api.
 
 444 The WH api currently has three large shortcomings. It is very hard to just get
 
 445 the current stock for an item, it's extremely complicated to use it to produce
 
 446 assemblies while ensuring that no stock ends up negative, and it's very hard to
 
 447 use it to get an overview over the actual contents of the inventory.
 
 449 The first problem has spawned several dozen small functions in the program that
 
 450 try to implement that, and those usually miss some details. They may ignore
 
 451 reservations, or reserve warehouses, or bestbefore times.
 
 453 To get this cleaned up a bit this code introduces two concepts: stock and onhand.
 
 455 Stock is defined as the actual contents of the inventory, everything that is
 
 456 there. Onhand is what is available, which means things that are stocked and not
 
 457 reserved and not expired.
 
 459 The two new functions C<get_stock> and C<get_onhand> encapsulate these principles and
 
 460 allow simple access with some optional filters for chargenumbers or warehouses.
 
 461 Both of them have a batch mode that can be used to get these information to
 
 462 supllement smiple reports.
 
 464 To address the safe assembly creation a new function has been added.
 
 465 C<allocate> will try to find the requested quantity of a part in the inventory
 
 466 and will return allocations of it which can then be used to create the
 
 467 assembly. Allocation will happen with the C<onhand> semantics defined above,
 
 468 meaning that by default no reservations or expired goods will be used. The
 
 469 caller can supply hints of what shold be used and in those cases chargenumber
 
 470 and reservations will be used up as much as possible first.  C<allocate> will
 
 471 always try to fulfil the request even beyond those. Should the required amount
 
 472 not be stocked, allocate will throw an exception.
 
 474 C<produce_assembly> has been rewritten to only accept parameters about the
 
 475 target of the production, and requires allocations to complete the request. The
 
 476 allocations can be supplied manually, or can be generated automatically.
 
 477 C<produce_assembly> will check whether enough allocations are given to create
 
 478 the recipe, but will not check whether the allocations are backed. If the
 
 479 allocations are not sufficient or if the auto-allocation fails an exception
 
 480 is returned. If you need to produce something that is not in the inventory, you
 
 481 can bypass those checks by creating the allocations yourself (see
 
 482 L</"ALLOCATION DATA STRUCTURE">).
 
 484 Note: this is only intended to cover the scenarios described above. For other cases:
 
 490 If you need the reserved amount for an order use C<SL::DB::Helper::Reservation>
 
 495 If you need actual inventory objects because of record links, prod_id links or
 
 496 something like that load them directly. And strongly consider redesigning that,
 
 497 because it's really fragile.
 
 501 You need weight or accounting information you're on your own. The inventory api
 
 502 only concerns itself with the raw quantities.
 
 506 If you need the first stock date of parts, or anything related to a specific
 
 507 transfer type or direction, this is not covered yet.
 
 515 =item * get_stock PARAMS
 
 517 Returns for single parts how much actually exists in the inventory.
 
 525 The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
 
 529 If given, will only return stock on these bins. Optional. May be array, May be object or id.
 
 533 If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
 
 537 If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
 
 541 If given, will only show stock with this chargenumber. Optional. May be array.
 
 545 See L</"STOCK/ONHAND REPORT MODE">
 
 549 See L</"STOCK/ONHAND REPORT MODE">
 
 553 Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
 
 554 mode when C<by> is given.
 
 556 =item * get_onhand PARAMS
 
 558 Returns for single parts how much is available in the inventory. That excludes:
 
 559 reserved quantities, reserved warehouses and stock with expired bestbefore.
 
 561 It takes all options of L</get_stock> but treats some of the differently and has some additional ones:
 
 567 Usually C<onhand> will not include results from warehouses with the C<reserve>
 
 568 flag. However giving an explicit list of warehouses will include there in the
 
 569 search, as well as all others.
 
 573 =item * reserve_warehouse
 
 579 =item * allocate PARAMS
 
 591 Bin object. Optional.
 
 595 Warehouse object. Optional.
 
 607 Needs to be a rose object, where id and table can be extracted. Optional.
 
 611 Tries to allocate the required quantity using what is currently onhand. If
 
 612 given any of C<bin>, C<warehouse>, C<chargenumber>, C<reserve_for>
 
 615 =item * allocate_for_assembly PARAMS
 
 617 Shortcut to allocate everything for an assembly. Takes the same arguments. Will
 
 618 compute the required amount for each assembly part and allocate all of them.
 
 620 =item * produce_assembly
 
 625 =head1 STOCK/ONHAND REPORT MODE
 
 627 If the special option C<by> is given with an arrayref, the result will instead
 
 628 be an arrayref of partitioned stocks by those fields. Valid partitions are:
 
 634 If this is given, part is optional in the parameters
 
 648 Note: If you want to use the returned data to create allocations you I<need> to
 
 649 enable all of these. To make this easier a special shortcut exists
 
 651 In this mode, C<with_objects> can be used to load C<warehouse>, C<bin>,
 
 652 C<parts>, and the C<reserve_for> objects in one go, just like with Rose. They
 
 653 need to be present in C<by> before that though.
 
 655 =head1 ALLOCATION ALGORITHM
 
 657 When calling allocate, the current onhand (== available stock) of the item will
 
 658 be used to decide which bins/chargenumbers/bestbefore can be used.
 
 660 In general allocate will try to make the request happen, and will use the
 
 661 provided charges up first, and then tap everything else. If you need to only
 
 662 I<exactly> use the provided charges, you'll need to craft the allocations
 
 663 yourself. See L</"ALLOCATION DATA STRUCTURE"> for that.
 
 665 If C<reserve_for> is given, those will be used up first too.
 
 667 If C<reserved_warehouse> is given, those will be used up second.
 
 669 If C<chargenumber> is given, those will be used up next.
 
 671 After that normal quantities will be used.
 
 673 These are tiebreakers and expected to rarely matter in reality. If you need
 
 674 finegrained control over which allocation is used, you may want to get the
 
 675 onhands yourself and select the appropriate ones.
 
 677 Only quantities with C<bestbefore> unset or after the given date will be
 
 678 considered. If more than one charge is eligible, the earlier C<bestbefore>
 
 681 Allocations do NOT have an internal memory and can't react to other allocations
 
 682 of the same part earlier. Never double allocate the same part within a
 
 685 =head1 ALLOCATION DATA STRUCTURE
 
 687 Allocations are instances of the helper class C<SL::Helper::Inventory::Allocation>. They require
 
 688 each of the following attributes to be set at creation time:
 
 704 =item * reserve_for_id
 
 706 =item * reserve_for_table
 
 710 C<chargenumber>, C<bestbefore>, C<reserve_for_id> and C<reserve_for_table> may
 
 711 be C<undef> (but must still be present at creation time). Instances are
 
 712 considered immutable.
 
 714 =head1 ERROR HANDLING
 
 716 C<allocate> and C<produce_assembly> will throw exceptions if the request can
 
 717 not be completed. The usual reason will be insufficient onhand to allocate, or
 
 718 insufficient allocations to process the request.
 
 722   * define and describe error classes
 
 723   * define wrapper classes for stock/onhand batch mode return values
 
 724   * handle extra arguments in produce: shippingdate, project, oe
 
 725   * clean up allocation helper class
 
 726   * with objects for reservations
 
 736 Sven Schöling E<lt>sven.schoeling@opendynamic.deE<gt>