6f18de9df05155573393b358008bcc1b01c13268
[kivitendo-erp.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);
8 use List::UtilsBy qw(sort_by);
9 use List::MoreUtils qw(any);
10
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;
15 use SL::X;
16
17 our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly);
18 our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
19
20 sub _get_stock_onhand {
21   my (%params) = @_;
22
23   my $onhand_mode = !!$params{onhand};
24
25   my @selects = ('SUM(qty) as qty');
26   my @values;
27   my @where;
28   my @groups;
29
30   if ($params{part}) {
31     my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
32     push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
33     push @values, @ids;
34   }
35
36   if ($params{bin}) {
37     my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
38     push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
39     push @values, @ids;
40   }
41
42   if ($params{warehouse}) {
43     my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
44     push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
45     push @values, @ids;
46   }
47
48   if ($params{chargenumber}) {
49     my @ids = listify($params{chargenumber});
50     push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
51     push @values, @ids;
52   }
53
54   if ($params{date}) {
55     push @where, sprintf "shippingdate <= ?";
56     push @values, $params{date};
57   }
58
59   if ($params{bestbefore}) {
60     push @where, sprintf "bestbefore >= ?";
61     push @values, $params{bestbefore};
62   }
63
64   # reserve_warehouse
65   if ($params{onhand} && !$params{warehouse}) {
66     push @where, 'NOT warehouse.forreserve';
67   }
68
69   # reserve_for
70   if ($params{onhand} && !$params{reserve_for}) {
71     push @where, 'reserve_for_id IS NULL AND reserve_for_table IS NULL';
72   }
73
74   if ($params{reserve_for}) {
75     my @objects = listify($params{chargenumber});
76     my @tokens;
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) . ')';
80   }
81
82   # by
83   my %allowed_by = (
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) ],
91   );
92
93   if ($params{by}) {
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;
98     }
99   }
100
101   my $select   = join ',', @selects;
102   my $where    = @where  ? 'WHERE ' . join ' AND ', @where : '';
103   my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
104
105   my $query = <<"";
106     SELECT $select FROM inventory
107     LEFT JOIN bin ON bin_id = bin.id
108     LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
109     $where
110     $group_by
111     HAVING SUM(qty) > 0
112
113   my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
114
115   my %with_objects = (
116     part         => 'SL::DB::Manager::Part',
117     bin          => 'SL::DB::Manager::Bin',
118     warehouse    => 'SL::DB::Manager::Warehouse',
119     reserve_for  => undef,
120   );
121
122   my %slots = (
123     part      =>  'parts_id',
124     bin       =>  'bin_id',
125     warehouse =>  'warehouse_id',
126   );
127
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};
131
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;
137
138         $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
139       } else {
140         # need to fetch all reserve_for_table partitions
141       }
142     }
143   }
144
145   if ($params{by}) {
146     return $results;
147   } else {
148     return $results->[0]{qty};
149   }
150 }
151
152 sub get_stock {
153   _get_stock_onhand(@_, onhand => 0);
154 }
155
156 sub get_onhand {
157   _get_stock_onhand(@_, onhand => 1);
158 }
159
160 sub allocate {
161   my (%params) = @_;
162
163   my $part = $params{part} or Carp::croak('allocate needs a part');
164   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
165
166   return () if $qty <= 0;
167
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});
175   }
176
177   # filter the results. we don't want:
178   # - negative amounts
179   # - bins that are reserve but not in the white-list of warehouses or bins
180   # - reservations that are not white-listed
181
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} })
185   } @$results;
186
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
193   } @filtered_results;
194
195   my @allocations;
196   my $rest_qty = $qty;
197
198   for my $chunk (@sorted_results) {
199     my $qty = min($chunk->{qty}, $rest_qty);
200     if ($qty > 0) {
201       push @allocations, SL::Helper::Inventory::Allocation->new(
202         parts_id          => $chunk->{parts_id},
203         qty               => $qty,
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},
211       );
212       $rest_qty -= $qty;
213     }
214
215     last if $rest_qty == 0;
216   }
217
218   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),
222     );
223   } else {
224     return @allocations;
225   }
226 }
227
228 sub allocate_for_assembly {
229   my (%params) = @_;
230
231   my $part = $params{part} or Carp::croak('allocate needs a part');
232   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
233
234   Carp::croak('not an assembly') unless $part->is_assembly;
235
236   my %parts_to_allocate;
237
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
241   }
242
243   my @allocations;
244
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});
248   }
249
250   @allocations;
251 }
252
253 sub produce_assembly {
254   my (%params) = @_;
255
256   my $part = $params{part} or Carp::croak('allocate needs a part');
257   my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
258
259   my $allocations = $params{allocations};
260   if (!$allocations && $params{auto_allocate}) {
261     $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ];
262   } else {
263     Carp::croak("need allocations or auto_allocate to produce something") unless $allocations;
264   }
265
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} // '';
270
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};
275
276   my $reserve_for_id    = $reserve_for ? $reserve_for->id          : undef;
277   my $reserve_for_table = $reserve_for ? $reserve_for->meta->table : undef;
278
279   my $shippingdate = $params{shippingsdate} // DateTime->now_local;
280
281   my $trans_id              = $params{trans_id};
282   ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
283
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');
286
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
292     }
293
294     die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part;
295   }
296
297   my @transfers;
298   for my $allocation (@$allocations) {
299     push @transfers, SL::DB::Inventory->new(
300       trans_id     => $trans_id,
301       %$allocation,
302       qty          => -$allocation->qty,
303       trans_type   => $trans_type_out,
304       shippingdate => $shippingdate,
305       employee     => SL::DB::Manager::Employee->current,
306     );
307   }
308
309   push @transfers, SL::DB::Inventory->new(
310     trans_id          => $trans_id,
311     trans_type        => $trans_type_in,
312     part              => $part,
313     qty               => $qty,
314     bin               => $bin,
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,
321     project           => $project,
322     invoice           => $invoice,
323     comment           => $comment,
324     prod              => $production_order_item,
325     employee          => SL::DB::Manager::Employee->current,
326   );
327
328   SL::DB->client->with_transaction(sub {
329     $_->save for @transfers;
330     1;
331   }) or do {
332     die SL::DB->client->error;
333   };
334
335   @transfers;
336 }
337
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;
341
342   for my $name (@attributes) {
343     no strict 'refs';
344     *{"SL::Helper::Inventory::Allocation::$name"} = sub { $_[0]{$name} };
345   }
346
347   sub new {
348     my ($class, %params) = @_;
349
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);
354
355     bless { %params }, $class;
356   }
357 }
358
359 1;
360
361 =encoding utf-8
362
363 =head1 NAME
364
365 SL::WH - Warehouse and Inventory API
366
367 =head1 SYNOPSIS
368
369   # See description for an intro to the concepts used here.
370
371   use SL::Helper::Inventory;
372
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?
379
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?
387
388   # onhand batch mode:
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) ],
393   );
394
395   # allocate:
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
403   );
404
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
409   );
410
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,
414     qty               => 15,
415     bin_id            => $bin_obj->id,
416     warehouse_id      => $bin_obj->warehouse_id,
417     chargenumber      => '1823772365',
418     bestbefore        => undef,
419     reserve_for_id    => undef,
420     reserve_for_table => undef,
421   );
422
423   # produce_assembly:
424   SL::Helper::Inventory->produce_assembly(
425     part         => $part,           # target assembly
426     qty          => $qty,            # qty
427     allocations  => \@allocations,   # allocations to use. alternatively use "auto_allocate => 1,"
428
429     # where to put it
430     bin          => $bin,           # needed unless a global standard target is configured
431     chargenumber => $chargenumber,  # optional
432     bestbefore   => $datetime,      # optional
433     comment      => $comment,       # optional
434
435     # links, all optional
436     production_order_item => $item,
437     reserve_for           => $object,
438   );
439
440 =head1 DESCRIPTION
441
442 New functions for the warehouse and inventory api.
443
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.
448
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.
452
453 To get this cleaned up a bit this code introduces two concepts: stock and onhand.
454
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.
458
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.
463
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.
473
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">).
483
484 Note: this is only intended to cover the scenarios described above. For other cases:
485
486 =over 4
487
488 =item *
489
490 If you need the reserved amount for an order use C<SL::DB::Helper::Reservation>
491 instead.
492
493 =item *
494
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.
498
499 =item *
500
501 You need weight or accounting information you're on your own. The inventory api
502 only concerns itself with the raw quantities.
503
504 =item *
505
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.
508
509 =back
510
511 =head1 FUNCTIONS
512
513 =over 4
514
515 =item * get_stock PARAMS
516
517 Returns for single parts how much actually exists in the inventory.
518
519 Options:
520
521 =over 4
522
523 =item * part
524
525 The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
526
527 =item * bin
528
529 If given, will only return stock on these bins. Optional. May be array, May be object or id.
530
531 =item * warehouse
532
533 If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
534
535 =item * date
536
537 If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
538
539 =item * chargenumber
540
541 If given, will only show stock with this chargenumber. Optional. May be array.
542
543 =item * by
544
545 See L</"STOCK/ONHAND REPORT MODE">
546
547 =item * with_objects
548
549 See L</"STOCK/ONHAND REPORT MODE">
550
551 =back
552
553 Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
554 mode when C<by> is given.
555
556 =item * get_onhand PARAMS
557
558 Returns for single parts how much is available in the inventory. That excludes:
559 reserved quantities, reserved warehouses and stock with expired bestbefore.
560
561 It takes all options of L</get_stock> but treats some of the differently and has some additional ones:
562
563 =over 4
564
565 =item * warehouse
566
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.
570
571 =item * reserve_for
572
573 =item * reserve_warehouse
574
575 =item * bestbefore
576
577 =back
578
579 =item * allocate PARAMS
580
581 Accepted parameters:
582
583 =over 4
584
585 =item * part
586
587 =item * qty
588
589 =item * bin
590
591 Bin object. Optional.
592
593 =item * warehouse
594
595 Warehouse object. Optional.
596
597 =item * chargenumber
598
599 Optional.
600
601 =item * bestbefore
602
603 Datetime. Optional.
604
605 =item * reserve_for
606
607 Needs to be a rose object, where id and table can be extracted. Optional.
608
609 =back
610
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>
613
614
615 =item * allocate_for_assembly PARAMS
616
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.
619
620 =item * produce_assembly
621
622
623 =back
624
625 =head1 STOCK/ONHAND REPORT MODE
626
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:
629
630 =over 4
631
632 =item * part
633
634 If this is given, part is optional in the parameters
635
636 =item * bin
637
638 =item * warehouse
639
640 =item * chargenumber
641
642 =item * bestbefore
643
644 =item * reserve_for
645
646 =back
647
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
650
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.
654
655 =head1 ALLOCATION ALGORITHM
656
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.
659
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.
664
665 If C<reserve_for> is given, those will be used up first too.
666
667 If C<reserved_warehouse> is given, those will be used up second.
668
669 If C<chargenumber> is given, those will be used up next.
670
671 After that normal quantities will be used.
672
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.
676
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>
679 will be used.
680
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
683 transaction.
684
685 =head1 ALLOCATION DATA STRUCTURE
686
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:
689
690 =over 4
691
692 =item * parts_id
693
694 =item * qty
695
696 =item * bin_id
697
698 =item * warehouse_id
699
700 =item * chargenumber
701
702 =item * bestbefore
703
704 =item * reserve_for_id
705
706 =item * reserve_for_table
707
708 =back
709
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.
713
714 =head1 ERROR HANDLING
715
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.
719
720 =head1 TODO
721
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
727   * document no_ check
728   * tests
729
730 =head1 BUGS
731
732 None yet :)
733
734 =head1 AUTHOR
735
736 Sven Schöling E<lt>sven.schoeling@opendynamic.deE<gt>
737
738 =cut