]> wagnertech.de Git - mfinanz.git/blob - SL/Helper/Inventory.pm
Merge branch 'master' of http://wagnertech.de/git/mfinanz
[mfinanz.git] / SL / Helper / Inventory.pm
1 package SL::Helper::Inventory;
2
3 use strict;
4 use Carp;
5 use DateTime;
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 none);
10 use POSIX qw(ceil);
11 use Scalar::Util qw(blessed);
12
13 use SL::Locale::String qw(t8);
14 use SL::MoreCommon qw(listify);
15 use SL::DBUtils qw(selectall_hashref_query selectrow_query);
16 use SL::DB::TransferType;
17 use SL::Helper::Number qw(_format_number _round_number);
18 use SL::Helper::Inventory::Allocation;
19 use SL::X;
20
21 our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly check_constraints check_allocations_for_assembly);
22 our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
23
24 sub _get_stock_onhand {
25   my (%params) = @_;
26
27   my $onhand_mode = !!$params{onhand};
28
29   my @selects = (
30     'SUM(qty) AS qty',
31     'MIN(EXTRACT(epoch FROM inventory.itime)) AS itime',
32   );
33   my @values;
34   my @where;
35   my @groups;
36
37   if ($params{part}) {
38     my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
39     push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
40     push @values, @ids;
41   }
42
43   if ($params{bin}) {
44     my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
45     push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
46     push @values, @ids;
47   }
48
49   if ($params{warehouse}) {
50     my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
51     push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
52     push @values, @ids;
53   }
54
55   if ($params{chargenumber}) {
56     my @ids = listify($params{chargenumber});
57     push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
58     push @values, @ids;
59   }
60
61   if ($params{date}) {
62     Carp::croak("not DateTime ".$params{date}) unless ref($params{date}) eq 'DateTime';
63     push @where, sprintf "shippingdate <= ?";
64     push @values, $params{date};
65   }
66
67   if (!$params{bestbefore} && $onhand_mode && default_show_bestbefore()) {
68     $params{bestbefore} = DateTime->now_local;
69   }
70
71   if ($params{bestbefore}) {
72     Carp::croak("not DateTime ".$params{date}) unless ref($params{bestbefore}) eq 'DateTime';
73     push @where, sprintf "(bestbefore IS NULL OR bestbefore >= ?)";
74     push @values, $params{bestbefore};
75   }
76
77   # by
78   my %allowed_by = (
79     part          => [ qw(parts_id) ],
80     bin           => [ qw(bin_id inventory.warehouse_id)],
81     warehouse     => [ qw(inventory.warehouse_id) ],
82     chargenumber  => [ qw(chargenumber) ],
83     bestbefore    => [ qw(bestbefore) ],
84     for_allocate  => [ qw(parts_id bin_id inventory.warehouse_id chargenumber bestbefore) ],
85   );
86
87   if ($params{by}) {
88     for (listify($params{by})) {
89       my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_");
90       push @selects, @$selects;
91       push @groups,  @$selects;
92     }
93   }
94
95   my $select   = join ',', @selects;
96   my $where    = @where  ? 'WHERE ' . join ' AND ', @where : '';
97   my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
98
99   my $query = <<"";
100     SELECT $select FROM inventory
101     LEFT JOIN bin ON bin_id = bin.id
102     LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
103     $where
104     $group_by
105
106   if ($onhand_mode) {
107     $query .= ' HAVING SUM(qty) > 0';
108   }
109
110   my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
111
112   my %with_objects = (
113     part         => 'SL::DB::Manager::Part',
114     bin          => 'SL::DB::Manager::Bin',
115     warehouse    => 'SL::DB::Manager::Warehouse',
116   );
117
118   my %slots = (
119     part      =>  'parts_id',
120     bin       =>  'bin_id',
121     warehouse =>  'warehouse_id',
122   );
123
124   if ($params{by} && $params{with_objects}) {
125     for my $with_object (listify($params{with_objects})) {
126       Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object};
127
128       my $manager = $with_objects{$with_object};
129       my $slot = $slots{$with_object};
130       next if !(my @ids = map { $_->{$slot} } @$results);
131       my $objects = $manager->get_all(query => [ id => \@ids ]);
132       my %objects_by_id = map { $_->id => $_ } @$objects;
133
134       $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
135     }
136   }
137
138   if ($params{by}) {
139     return $results;
140   } else {
141     return $results->[0]{qty};
142   }
143 }
144
145 sub get_stock {
146   _get_stock_onhand(@_, onhand => 0);
147 }
148
149 sub get_onhand {
150   _get_stock_onhand(@_, onhand => 1);
151 }
152
153 sub allocate {
154   my (%params) = @_;
155
156   croak('allocate needs a part') unless $params{part};
157   croak('allocate needs a qty')  unless $params{qty};
158
159   my $part = $params{part};
160   my $qty  = $params{qty};
161
162   return () if $qty <= 0;
163
164   my $results = get_stock(part => $part, by => 'for_allocate');
165   my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{bin});
166   my %wh_whitelist  = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{warehouse});
167   my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{chargenumber});
168
169   # sort results so that chargenumbers are matched first, then wanted bins, then wanted warehouses
170   my @sorted_results = sort {
171        exists $chargenumbers{$b->{chargenumber}}  <=> exists $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers
172     || exists $bin_whitelist{$b->{bin_id}}        <=> exists $bin_whitelist{$a->{bin_id}}       # then prefer wanted bins
173     || exists $wh_whitelist{$b->{warehouse_id}}   <=> exists $wh_whitelist{$a->{warehouse_id}}  # then prefer wanted bins
174     || $a->{itime}                                <=> $b->{itime}                               # and finally prefer earlier charges
175   } @$results;
176   my @allocations;
177   my $rest_qty = $qty;
178
179   for my $chunk (@sorted_results) {
180     my $qty = min($chunk->{qty}, $rest_qty);
181
182     # since allocate operates on stock, this also ensures that no negative stock results are used
183     if ($qty > 0) {
184       push @allocations, SL::Helper::Inventory::Allocation->new(
185         parts_id          => $chunk->{parts_id},
186         qty               => $qty,
187         comment           => $params{comment},
188         bin_id            => $chunk->{bin_id},
189         warehouse_id      => $chunk->{warehouse_id},
190         chargenumber      => $chunk->{chargenumber},
191         bestbefore        => $chunk->{bestbefore},
192         for_object_id     => undef,
193       );
194       $rest_qty -=  _round_number($qty, 5);
195     }
196     $rest_qty = _round_number($rest_qty, 5);
197     last if $rest_qty == 0;
198   }
199   if ($rest_qty > 0) {
200     die SL::X::Inventory::Allocation::MissingQty->new(
201       code             => 'not enough to allocate',
202       message          => t8("can not allocate #1 units of #2, missing #3 units", _format_number($qty), $part->displayable_name, _format_number($rest_qty)),
203       part_description => $part->displayable_name,
204       to_allocate_qty  => $qty,
205       missing_qty      => $rest_qty,
206     );
207   } else {
208     if ($params{constraints}) {
209       check_constraints($params{constraints},\@allocations);
210     }
211     return @allocations;
212   }
213 }
214
215 sub allocate_for_assembly {
216   my (%params) = @_;
217
218   my $part = $params{part} or Carp::croak('allocate needs a part');
219   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
220   my $wh   = $params{warehouse};
221   my $wh_strict       = $::instance_conf->get_produce_assembly_same_warehouse;
222   my $consume_service = $::instance_conf->get_produce_assembly_transfer_service;
223
224   Carp::croak('not an assembly')       unless $part->is_assembly;
225   Carp::croak('No warehouse selected') if $wh_strict && !$wh;
226
227   my %parts_to_allocate;
228
229   for my $assembly ($part->assemblies) {
230     next if $assembly->part->type eq 'service' && !$consume_service;
231     $parts_to_allocate{ $assembly->part->id } //= 0;
232     $parts_to_allocate{ $assembly->part->id } += $assembly->qty * $qty;
233   }
234
235   my (@allocations, @errors);
236
237   for my $part_id (keys %parts_to_allocate) {
238     my $part = SL::DB::Part->load_cached($part_id);
239
240     eval {
241       push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id});
242       if ($wh_strict) {
243         die SL::X::Inventory::Allocation->new(
244           code    => "wrong warehouse for part",
245           message => t8('Part #1 exists in warehouse #2, but not in warehouse #3 ',
246                           $part->partnumber . ' ' . $part->description,
247                           SL::DB::Manager::Warehouse->find_by(id => $allocations[-1]->{warehouse_id})->description,
248                           $wh->description),
249         ) unless $allocations[-1]->{warehouse_id} == $wh->id;
250       }
251       1;
252     } or do {
253       my $ex = $@;
254       die $ex unless blessed($ex) && $ex->can('rethrow');
255
256       if ($ex->isa('SL::X::Inventory::Allocation')) {
257         push @errors, $@;
258       } else {
259         $ex->rethrow;
260       }
261     };
262   }
263
264   if (@errors) {
265     die SL::X::Inventory::Allocation::Multi->new(
266       code    => "multiple errors during allocation",
267       message => "multiple errors during allocation",
268       errors  => \@errors,
269     );
270   }
271
272   @allocations;
273 }
274
275 sub check_constraints {
276   my ($constraints, $allocations) = @_;
277   if ('CODE' eq ref $constraints) {
278     if (!$constraints->(@$allocations)) {
279       die SL::X::Inventory::Allocation->new(
280         code    => 'allocation constraints failure',
281         message => t8("Allocations didn't pass constraints"),
282       );
283     }
284   } else {
285     croak 'constraints needs to be a hashref' unless 'HASH' eq ref $constraints;
286
287     my %supported_constraints = (
288       bin_id       => 'bin_id',
289       warehouse_id => 'warehouse_id',
290       chargenumber => 'chargenumber',
291     );
292
293     for (keys %$constraints ) {
294       croak "unsupported constraint '$_'" unless $supported_constraints{$_};
295       next unless defined $constraints->{$_};
296
297       my %whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($constraints->{$_});
298       my $accessor = $supported_constraints{$_};
299
300       if (any { !$whitelist{$_->$accessor} } @$allocations) {
301         my %error_constraints = (
302           bin_id         => t8('Bins'),
303           warehouse_id   => t8('Warehouses'),
304           chargenumber   => t8('Chargenumbers'),
305         );
306         my @allocs = grep { $whitelist{$_->$accessor} } @$allocations;
307         my $needed = sum map { $_->qty } grep { !$whitelist{$_->$accessor} } @$allocations;
308         my $err    = t8("Cannot allocate parts.");
309         $err      .= ' '.t8('part \'#\'1 in bin \'#2\' only with qty #3 (need additional #4) and chargenumber \'#5\'.',
310               SL::DB::Part->load_cached($_->parts_id)->description,
311               SL::DB::Bin->load_cached($_->bin_id)->full_description,
312               _format_number($_->qty), _format_number($needed), $_->chargenumber ? $_->chargenumber : '--') for @allocs;
313         die SL::X::Inventory::Allocation->new(
314           code    => 'allocation constraints failure',
315           message => $err,
316         );
317       }
318     }
319   }
320 }
321
322 sub produce_assembly {
323   my (%params) = @_;
324
325   my $part = $params{part} or Carp::croak('produce_assembly needs a part');
326   my $qty  = $params{qty}  or Carp::croak('produce_assembly needs a qty');
327   my $bin  = $params{bin}  or Carp::croak("need target bin");
328
329   my $allocations = $params{allocations};
330   my $strict_wh = $::instance_conf->get_produce_assembly_same_warehouse ? $bin->warehouse : undef;
331   my $consume_service = $::instance_conf->get_produce_assembly_transfer_service;
332
333   if ($params{auto_allocate}) {
334     Carp::croak("produce_assembly: can't have both allocations and auto_allocate") if $params{allocations};
335     $allocations = [ allocate_for_assembly(part => $part, qty => $qty, warehouse => $strict_wh, chargenumber => $params{chargenumber}) ];
336   } else {
337     Carp::croak("produce_assembly: need allocations or auto_allocate to produce something") if !$params{allocations};
338     $allocations = $params{allocations};
339   }
340
341   my $chargenumber  = $params{chargenumber};
342   my $bestbefore    = $params{bestbefore};
343   my $for_object_id = $params{for_object_id};
344   my $comment       = $params{comment} // '';
345   my $invoice       = $params{invoice};
346   my $project       = $params{project};
347   my $shippingdate  = $params{shippingsdate} // DateTime->now_local;
348   my $trans_id      = $params{trans_id};
349
350   ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
351
352   my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used');
353   my $trans_type_in  = SL::DB::Manager::TransferType->find_by(direction => 'in',  description => 'assembled');
354
355   # check whether allocations are sane
356   if (!$params{no_check_allocations} && !$params{auto_allocate}) {
357     die SL::X::Inventory::Allocation->new(
358       code    => "allocations are insufficient for production",
359       message => t8('can not allocate enough resources for production'),
360     ) if !check_allocations_for_assembly(part => $part, qty => $qty, allocations => $allocations);
361   }
362
363   my @transfers;
364   for my $allocation (@$allocations) {
365     my $oe_id = delete $allocation->{for_object_id};
366     push @transfers, $allocation->transfer_object(
367       trans_id     => $trans_id,
368       qty          => -$allocation->qty,
369       trans_type   => $trans_type_out,
370       shippingdate => $shippingdate,
371       employee     => SL::DB::Manager::Employee->current,
372       comment      => t8('Used for assembly #1 #2', $part->partnumber, $part->description),
373     );
374   }
375
376   push @transfers, SL::DB::Inventory->new(
377     trans_id          => $trans_id,
378     trans_type        => $trans_type_in,
379     part              => $part,
380     qty               => $qty,
381     bin               => $bin,
382     warehouse         => $bin->warehouse_id,
383     chargenumber      => $chargenumber,
384     bestbefore        => $bestbefore,
385     shippingdate      => $shippingdate,
386     project           => $project,
387     invoice           => $invoice,
388     comment           => $comment,
389     employee          => SL::DB::Manager::Employee->current,
390     oe_id             => $for_object_id,
391   );
392
393   SL::DB->client->with_transaction(sub {
394     $_->save for @transfers;
395     1;
396   }) or do {
397     die SL::DB->client->error;
398   };
399
400   @transfers;
401 }
402
403 sub check_allocations_for_assembly {
404   my (%params) = @_;
405
406   my $part = $params{part} or Carp::croak('check_allocations_for_assembly needs a part');
407   my $qty  = $params{qty}  or Carp::croak('check_allocations_for_assembly needs a qty');
408
409   my $check_overfulfilment = !!$params{check_overfulfilment};
410   my $allocations          = $params{allocations};
411
412   my $consume_service      = $::instance_conf->get_produce_assembly_transfer_service;
413
414   my %allocations_by_part;
415   for (@{ $allocations || []}) {
416     $allocations_by_part{$_->parts_id} //= 0;
417     $allocations_by_part{$_->parts_id}  += $_->qty;
418   }
419
420   for my $assembly ($part->assemblies) {
421     next if $assembly->part->type eq 'service' && !$consume_service;
422     $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty;
423   }
424
425   return (none { $_ < 0 } values %allocations_by_part) && (!$check_overfulfilment || (none { $_ > 0 } values %allocations_by_part));
426 }
427
428 sub check_stock_out_transfer_requests {
429   my (%params) = @_;
430
431   my $transfer_requests = $params{transfer_requests} or Carp::croak('check_stock_out_transfer_requests needs transfer_requests');
432   my $default_transfer = $params{default_transfer} || 0;
433
434   my $grouped_qtys; # part_id -> bin_id -> chargenumber -> bestbefore -> qty;
435   my %part_ids;
436   my %bin_ids;
437   my %chargenumbers;
438   foreach my $request (@$transfer_requests) {
439     $grouped_qtys
440       ->{$request->parts_id}
441       ->{$request->bin_id}
442       ->{$request->chargenumber}
443       ->{$request->bestbefore} += -$request->qty; # qty is negative
444     $bin_ids{$request->bin_id} = 1;
445     $chargenumbers{$request->chargenumber} = 1;
446   }
447
448   my $stocks = get_stock(
449     by => [qw(part bin chargenumber bestbefore)],
450     part => [keys %$grouped_qtys],
451     bin  => [keys %bin_ids],
452     chargenumber => [keys %chargenumbers],
453   );
454
455   # make stock searchable
456   my $available_qty;
457   foreach my $stock (@$stocks) {
458     $available_qty
459       ->{$stock->{parts_id}}
460       ->{$stock->{bin_id}}
461       ->{$stock->{chargenumber}}
462       ->{DateTime->from_kivitendo($stock->{bestbefore}) || undef} = $stock->{qty};
463   }
464
465   my @missing_qtys;
466   foreach my $p_id (keys %{$grouped_qtys}) {
467     foreach my $b_id (keys %{$grouped_qtys->{$p_id}}) {
468       next if $default_transfer
469            && $::instance_conf->get_transfer_default_ignore_onhand
470            && $::instance_conf->get_bin_id_ignore_onhand eq $b_id;
471       foreach my $cn (keys %{$grouped_qtys->{$p_id}->{$b_id}}) {
472         foreach my $bb (keys %{$grouped_qtys->{$p_id}->{$b_id}->{$cn}}) {
473           my $available_stock = $available_qty->{$p_id}->{$b_id}->{$cn}->{$bb};
474           if ($available_stock < $grouped_qtys->{$p_id}->{$b_id}->{$cn}->{$bb}) {
475             my $part = SL::DB::Manager::Part->find_by(id => $p_id);
476             my $bin  = SL::DB::Manager::Bin->find_by(id => $b_id);
477             push @missing_qtys, {
478               missing_qty  => $grouped_qtys->{$p_id}->{$b_id}->{$cn}->{$bb} - $available_stock,
479               part         => $part,
480               bin          => $bin,
481               chargenumber => $cn,
482               bestbefore   => $bb
483             }
484           }
485         }
486       }
487     }
488   }
489
490   return @missing_qtys;
491 }
492
493 sub default_show_bestbefore {
494   $::instance_conf->get_show_bestbefore
495 }
496
497 1;
498
499 =encoding utf-8
500
501 =head1 NAME
502
503 SL::WH - Warehouse and Inventory API
504
505 =head1 SYNOPSIS
506
507   # See description for an intro to the concepts used here.
508
509   use SL::Helper::Inventory qw(:ALL);
510
511   # stock, get "what's there" for a part with various conditions:
512   my $qty = get_stock(part => $part);                              # how much is on stock?
513   my $qty = get_stock(part => $part, date => $date);               # how much was on stock at a specific time?
514   my $qty = get_stock(part => $part, bin => $bin);                 # how much is on stock in a specific bin?
515   my $qty = get_stock(part => $part, warehouse => $warehouse);     # how much is on stock in a specific warehouse?
516   my $qty = get_stock(part => $part, chargenumber => $chargenumber); # how much is on stock of a specific chargenumber?
517
518   # onhand, get "what's available" for a part with various conditions:
519   my $qty = get_onhand(part => $part);                              # how much is available?
520   my $qty = get_onhand(part => $part, date => $date);               # how much was available at a specific time?
521   my $qty = get_onhand(part => $part, bin => $bin);                 # how much is available in a specific bin?
522   my $qty = get_onhand(part => $part, warehouse => $warehouse);     # how much is available in a specific warehouse?
523   my $qty = get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber?
524
525   # onhand batch mode:
526   my $data = get_onhand(
527     warehouse    => $warehouse,
528     by           => [ qw(bin part chargenumber) ],
529     with_objects => [ qw(bin part) ],
530   );
531
532   # allocate:
533   my @allocations = allocate(
534     part         => $part,          # part_id works too
535     qty          => $qty,           # must be positive
536     chargenumber => $chargenumber,  # optional, may be arrayref. if provided these charges will be used first
537     bestbefore   => $datetime,      # optional, defaults to today. items with bestbefore prior to that date wont be used
538     bin          => $bin,           # optional, may be arrayref. if provided
539   );
540
541   # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate
542   my @allocations = allocate_for_assembly(
543     part         => $assembly,      # part_id works too
544     qty          => $qty,           # must be positive
545   );
546
547   # create allocation manually, bypassing checks. all of these need to be passed, even undefs
548   my $allocation = SL::Helper::Inventory::Allocation->new(
549     parts_id          => $part->id,
550     qty               => 15,
551     bin_id            => $bin_obj->id,
552     warehouse_id      => $bin_obj->warehouse_id,
553     chargenumber      => '1823772365',
554     bestbefore        => undef,
555     comment           => undef,
556     for_object_id     => $order->id,
557   );
558
559   # produce_assembly:
560   produce_assembly(
561     part         => $part,           # target assembly
562     qty          => $qty,            # qty
563     allocations  => \@allocations,   # allocations to use. alternatively use "auto_allocate => 1,"
564
565     # where to put it
566     bin          => $bin,           # needed unless a global standard target is configured
567     chargenumber => $chargenumber,  # optional
568     bestbefore   => $datetime,      # optional
569     comment      => $comment,       # optional
570   );
571
572 =head1 DESCRIPTION
573
574 New functions for the warehouse and inventory api.
575
576 The WH api currently has three large shortcomings: It is very hard to just get
577 the current stock for an item, it's extremely complicated to use it to produce
578 assemblies while ensuring that no stock ends up negative, and it's very hard to
579 use it to get an overview over the actual contents of the inventory.
580
581 The first problem has spawned several dozen small functions in the program that
582 try to implement that, and those usually miss some details. They may ignore
583 bestbefore times, comments, ignore negative quantities etc.
584
585 To get this cleaned up a bit this code introduces two concepts: stock and onhand.
586
587 =over 4
588
589 =item * Stock is defined as the actual contents of the inventory, everything that is
590 there.
591
592 =item * Onhand is what is available, which means things that are stocked,
593 not expired and not in any other way reserved for other uses.
594
595 =back
596
597 The two new functions C<get_stock> and C<get_onhand> encapsulate these principles and
598 allow simple access with some optional filters for chargenumbers or warehouses.
599 Both of them have a batch mode that can be used to get these information to
600 supplement simple reports.
601
602 To address the safe assembly creation a new function has been added.
603 C<allocate> will try to find the requested quantity of a part in the inventory
604 and will return allocations of it which can then be used to create the
605 assembly. Allocation will happen with the C<onhand> semantics defined above,
606 meaning that by default no expired goods will be used. The caller can supply
607 hints of what shold be used and in those cases chargenumbers will be used up as
608 much as possible first. C<allocate> will always try to fulfil the request even
609 beyond those. Should the required amount not be stocked, allocate will throw an
610 exception.
611
612 C<produce_assembly> has been rewritten to only accept parameters about the
613 target of the production, and requires allocations to complete the request. The
614 allocations can be supplied manually, or can be generated automatically.
615 C<produce_assembly> will check whether enough allocations are given to create
616 the assembly, but will not check whether the allocations are backed. If the
617 allocations are not sufficient or if the auto-allocation fails an exception
618 is returned. If you need to produce something that is not in the inventory, you
619 can bypass those checks by creating the allocations yourself (see
620 L</"ALLOCATION DATA STRUCTURE">).
621
622 Note: this is only intended to cover the scenarios described above. For other cases:
623
624 =over 4
625
626 =item *
627
628 If you need actual inventory objects because of record links or something like
629 that load them directly. And strongly consider redesigning that, because it's
630 really fragile.
631
632 =item *
633
634 You need weight or accounting information you're on your own. The inventory api
635 only concerns itself with the raw quantities.
636
637 =item *
638
639 If you need the first stock date of parts, or anything related to a specific
640 transfer type or direction, this is not covered yet.
641
642 =back
643
644 =head1 FUNCTIONS
645
646 =over 4
647
648 =item * get_stock PARAMS
649
650 Returns for single parts how much actually exists in the inventory.
651
652 Options:
653
654 =over 4
655
656 =item * part
657
658 The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
659
660 =item * bin
661
662 If given, will only return stock on these bins. Optional. May be array, May be object or id.
663
664 =item * warehouse
665
666 If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
667
668 =item * date
669
670 If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
671
672 =item * chargenumber
673
674 If given, will only show stock with this chargenumber. Optional. May be array.
675
676 =item * by
677
678 See L</"STOCK/ONHAND REPORT MODE">
679
680 =item * with_objects
681
682 See L</"STOCK/ONHAND REPORT MODE">
683
684 =back
685
686 Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
687 mode when C<by> is given.
688
689 =item * get_onhand PARAMS
690
691 Returns for single parts how much is available in the inventory. That excludes
692 stock with expired bestbefore.
693
694 It takes the same options as L</get_stock>.
695
696 =over 4
697
698 =item * bestbefore
699
700 If given, will only return stock with a bestbefore at or after the given date.
701 Optional. Must be L<DateTime> object.
702
703 =back
704
705 =item * allocate PARAMS
706
707 Accepted parameters:
708
709 =over 4
710
711 =item * part
712
713 =item * qty
714
715 =item * bin
716
717 Bin object. Optional.
718
719 =item * warehouse
720
721 Warehouse object. Optional.
722
723 =item * chargenumber
724
725 Optional.
726
727 =item * bestbefore
728
729 Datetime. Optional.
730
731 =back
732
733 Tries to allocate the required quantity using what is currently onhand. If
734 given any of C<bin>, C<warehouse>, C<chargenumber>
735
736 =item * allocate_for_assembly PARAMS
737
738 Shortcut to allocate everything for an assembly. Takes the same arguments. Will
739 compute the required amount for each assembly part and allocate all of them.
740
741 =item * produce_assembly
742
743 =item * check_allocations_for_assembly PARAMS
744
745 Checks if enough quantity is allocated for production. Returns a trueish
746 value if there is enough allocated, a falsish one otherwise (but see the
747 parameter C<check_overfulfilment>).
748
749 Accepted parameters:
750
751 =over 4
752
753 =item * part
754
755 The part object to be assembled. Mandatory.
756
757 =item * qty
758
759 The quantity of the part to be assembled. Mandatory.
760
761 =item * allocations
762
763 An array ref of the allocations.
764
765 =item * check_overfulfilment
766
767 Whether or not overfulfilment should be checked. If more quantity is allocated
768 than needed for production a falsish value is returned. Optional.
769
770 =back
771
772 =item * check_stock_out_transfer_requests PARAMS
773
774 Checks if enough stock is availbale for the transfer requests. Returns a list
775 of missing quantities as hashref with the keys C<part>, C<bin>, C<missing_qty>, C<chargenumber>
776 and C<bestbefore>. C<chargenumber> and C<bestbefore> can be C<undef> if not set
777 in the transfer requests. 
778
779 Accepted parameters:
780
781 =over 4
782
783 =item * transfer_requests
784
785 Transfer requests to stock out as arrayref. Mandatory.
786
787 =item * default_transfer
788
789 Has to be trueish if the transfer requests are for a delivery order called with
790 'Transfer out via default'. Optional, Default 0.
791
792 =back
793
794 =back
795
796 =head1 STOCK/ONHAND REPORT MODE
797
798 If the special option C<by> is given with an arrayref, the result will instead
799 be an arrayref of partitioned stocks by those fields. Valid partitions are:
800
801 =over 4
802
803 =item * part
804
805 If this is given, part is optional in the parameters
806
807 =item * bin
808
809 =item * warehouse
810
811 =item * chargenumber
812
813 =item * bestbefore
814
815 =back
816
817 Note: If you want to use the returned data to create allocations you I<need> to
818 enable all of these. To make this easier a special shortcut exists
819
820 In this mode, C<with_objects> can be used to load C<warehouse>, C<bin>,
821 C<parts>  objects in one go, just like with Rose. They
822 need to be present in C<by> before that though.
823
824 =head1 ALLOCATION ALGORITHM
825
826 When calling allocate, the current onhand (== available stock) of the item will
827 be used to decide which bins/chargenumbers/bestbefore can be used.
828
829 In general allocate will try to make the request happen, and will use the
830 provided charges up first, and then tap everything else. If you need to only
831 I<exactly> use the provided charges, you'll need to craft the allocations
832 yourself. See L</"ALLOCATION DATA STRUCTURE"> for that.
833
834 If C<chargenumber> is given, those will be used up next.
835
836 After that normal quantities will be used.
837
838 These are tiebreakers and expected to rarely matter in reality. If you need
839 finegrained control over which allocation is used, you may want to get the
840 onhands yourself and select the appropriate ones.
841
842 Only quantities with C<bestbefore> unset or after the given date will be
843 considered. If more than one charge is eligible, the earlier C<bestbefore>
844 will be used.
845
846 Allocations do NOT have an internal memory and can't react to other allocations
847 of the same part earlier. Never double allocate the same part within a
848 transaction.
849
850 =head1 ALLOCATION DATA STRUCTURE
851
852 Allocations are instances of the helper class C<SL::Helper::Inventory::Allocation>. They require
853 each of the following attributes to be set at creation time:
854
855 =over 4
856
857 =item * parts_id
858
859 =item * qty
860
861 =item * bin_id
862
863 =item * warehouse_id
864
865 =item * chargenumber
866
867 =item * bestbefore
868
869 =item * comment
870
871 =item * for_object_id
872
873 If set the allocations will be marked as allocated for the given object.
874 If these allocations are later used to produce an assembly, the resulting
875 consuming transactions will be marked as belonging to the given object.
876 The object may be an order, productionorder or other objects
877
878 =back
879
880 C<chargenumber>, C<bestbefore> and C<for_object_id> and C<comment> may be
881 C<undef> (but must still be present at creation time). Instances are considered
882 immutable.
883
884 Allocations also provide the method C<transfer_object> which will create a new
885 C<SL::DB::Inventory> bject with all the playload.
886
887 =head1 CONSTRAINTS
888
889   # whitelist constraints
890   ->allocate(
891     ...
892     constraints => {
893       bin_id       => \@allowed_bins,
894       chargenumber => \@allowed_chargenumbers,
895     }
896   );
897
898   # custom constraints
899   ->allocate(
900     constraints => sub {
901       # only allow chargenumbers with specific format
902       all { $_->chargenumber =~ /^ C \d{8} - \a{d2} $/x } @_
903
904       &&
905       # and must all have a bestbefore date
906       all { $_->bestbefore } @_;
907     }
908   )
909
910 C<allocation> is "best effort" in nature. It will take the C<bin>,
911 C<chargenumber> etc hints from the parameters, but will try it's bvest to
912 fulfil the request anyway and only bail out if it is absolutely not possible.
913
914 Sometimes you need to restrict allocations though. For this you can pass
915 additional constraints to C<allocate>. A constraint serves as a whitelist.
916 Every allocation must fulfil every constraint by having that attribute be one
917 of the given values.
918
919 In case even that is not enough, you may supply a custom check by passing a
920 function that will be given the allocation objects.
921
922 Note that both whitelists and constraints do not influence the order of
923 allocations, which is done purely from the initial parameters. They only serve
924 to reject allocations made in good faith which do fulfil required assertions.
925
926 =head1 ERROR HANDLING
927
928 C<allocate> and C<produce_assembly> will throw exceptions if the request can
929 not be completed. The usual reason will be insufficient onhand to allocate, or
930 insufficient allocations to process the request.
931
932 =head1 KNOWN PROBLEMS
933
934   * It's not currently possible to identify allocations between requests, for
935     example for presenting the user possible allocations and then actually using
936     them on the next request.
937   * It's not currently possible to give C<allocate> prior constraints.
938     Currently all constraints are treated as hints (and will be preferred) but
939     the internal ordering of the hints is fixed and more complex preferentials
940     are not supported.
941   * bestbefore handling is untested
942   * interaction with config option "transfer_default_ignore_onhand" is
943     currently undefined (and implicitly ignores it)
944
945 =head1 TODO
946
947   * define and describe error classes
948   * define wrapper classes for stock/onhand batch mode return values
949   * handle extra arguments in produce: shippingdate, project
950   * document no_ check
951   * tests
952
953 =head1 BUGS
954
955 None yet :)
956
957 =head1 AUTHOR
958
959 Sven Schöling E<lt>sven.schoeling@googlemail.comE<gt>
960
961 =cut