Artikelstamm: "Erneuert am" aus parts_price_history holen …
[kivitendo-erp.git] / SL / DB / Part.pm
1 package SL::DB::Part;
2
3 use strict;
4
5 use Carp;
6 use List::MoreUtils qw(any uniq);
7 use Rose::DB::Object::Helpers qw(as_tree);
8
9 use SL::Locale::String qw(t8);
10 use SL::DBUtils;
11 use SL::DB::MetaSetup::Part;
12 use SL::DB::Manager::Part;
13 use SL::DB::Chart;
14 use SL::DB::Helper::AttrHTML;
15 use SL::DB::Helper::AttrSorted;
16 use SL::DB::Helper::TransNumberGenerator;
17 use SL::DB::Helper::CustomVariables (
18   module      => 'IC',
19   cvars_alias => 1,
20 );
21 use SL::DB::Helper::DisplayableNamePreferences (
22   title   => t8('Article'),
23   options => [ {name => 'partnumber',  title => t8('Part Number')     },
24                {name => 'description', title => t8('Description')    },
25                {name => 'notes',       title => t8('Notes')},
26                {name => 'ean',         title => t8('EAN')            }, ],
27 );
28
29 use List::Util qw(sum);
30
31 __PACKAGE__->meta->add_relationships(
32   assemblies                     => {
33     type         => 'one to many',
34     class        => 'SL::DB::Assembly',
35     manager_args => { sort_by => 'position' },
36     column_map   => { id => 'id' },
37   },
38   prices         => {
39     type         => 'one to many',
40     class        => 'SL::DB::Price',
41     column_map   => { id => 'parts_id' },
42     manager_args => { with_objects => [ 'pricegroup' ] }
43   },
44   makemodels     => {
45     type         => 'one to many',
46     class        => 'SL::DB::MakeModel',
47     manager_args => { sort_by => 'sortorder' },
48     column_map   => { id => 'parts_id' },
49   },
50   customerprices => {
51     type         => 'one to many',
52     class        => 'SL::DB::PartCustomerPrice',
53     column_map   => { id => 'parts_id' },
54   },
55   translations   => {
56     type         => 'one to many',
57     class        => 'SL::DB::Translation',
58     column_map   => { id => 'parts_id' },
59   },
60   assortment_items => {
61     type         => 'one to many',
62     class        => 'SL::DB::AssortmentItem',
63     column_map   => { id => 'assortment_id' },
64     manager_args => { sort_by => 'position' },
65   },
66   history_entries   => {
67     type            => 'one to many',
68     class           => 'SL::DB::History',
69     column_map      => { id => 'trans_id' },
70     query_args      => [ what_done => 'part' ],
71     manager_args    => { sort_by => 'itime' },
72   },
73   shop_parts     => {
74     type         => 'one to many',
75     class        => 'SL::DB::ShopPart',
76     column_map   => { id => 'part_id' },
77     manager_args => { with_objects => [ 'shop' ] },
78   },
79   last_price_update => {
80     type         => 'one to one',
81     class        => 'SL::DB::PartsPriceHistory',
82     column_map   => { id => 'part_id' },
83     manager_args => { sort_by => 'valid_from DESC', limit => 1 },
84   },
85 );
86
87 __PACKAGE__->meta->initialize;
88
89 __PACKAGE__->attr_html('notes');
90 __PACKAGE__->attr_sorted({ unsorted => 'makemodels',     position => 'sortorder' });
91 __PACKAGE__->attr_sorted({ unsorted => 'customerprices', position => 'sortorder' });
92
93 __PACKAGE__->before_save('_before_save_set_partnumber');
94
95 sub _before_save_set_partnumber {
96   my ($self) = @_;
97
98   $self->create_trans_number if !$self->partnumber;
99   return 1;
100 }
101
102 sub items {
103   my ($self) = @_;
104
105   if ( $self->part_type eq 'assembly' ) {
106     return $self->assemblies;
107   } elsif ( $self->part_type eq 'assortment' ) {
108     return $self->assortment_items;
109   } else {
110     return undef;
111   }
112 }
113
114 sub items_checksum {
115   my ($self) = @_;
116
117   # for detecting if the items of an (orphaned) assembly or assortment have
118   # changed when saving
119
120   return join(' ', sort map { $_->part->id } @{$self->items});
121 };
122
123 sub validate {
124   my ($self) = @_;
125
126   my @errors;
127   push @errors, $::locale->text('The partnumber is missing.')     if $self->id and !$self->partnumber;
128   push @errors, $::locale->text('The unit is missing.')           unless $self->unit;
129   push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
130
131   unless ( $self->id ) {
132     push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
133   };
134
135   if ($self->is_assortment && $self->orphaned && scalar @{$self->assortment_items} == 0) {
136     # when assortment isn't orphaned form doesn't contain any items
137     push @errors, $::locale->text('The assortment doesn\'t have any items.');
138   }
139
140   if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
141     push @errors, $::locale->text('The assembly doesn\'t have any items.');
142   }
143
144   return @errors;
145 }
146
147 sub is_type {
148   my $self = shift;
149   my $type  = lc(shift || '');
150   die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
151
152   return $self->type eq $type ? 1 : 0;
153 }
154
155 sub is_part       { $_[0]->part_type eq 'part'       }
156 sub is_assembly   { $_[0]->part_type eq 'assembly'   }
157 sub is_service    { $_[0]->part_type eq 'service'    }
158 sub is_assortment { $_[0]->part_type eq 'assortment' }
159
160 sub type {
161   return $_[0]->part_type;
162   # my ($self, $type) = @_;
163   # if (@_ > 1) {
164   #   die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
165   #   $self->assembly(          $type eq 'assembly' ? 1 : 0);
166   #   $self->inventory_accno_id($type ne 'service'  ? 1 : undef);
167   # }
168
169   # return 'assembly' if $self->assembly;
170   # return 'part'     if $self->inventory_accno_id;
171   # return 'service';
172 }
173
174 sub new_part {
175   my ($class, %params) = @_;
176   $class->new(%params, part_type => 'part');
177 }
178
179 sub new_assembly {
180   my ($class, %params) = @_;
181   $class->new(%params, part_type => 'assembly');
182 }
183
184 sub new_service {
185   my ($class, %params) = @_;
186   $class->new(%params, part_type => 'service');
187 }
188
189 sub new_assortment {
190   my ($class, %params) = @_;
191   $class->new(%params, part_type => 'assortment');
192 }
193
194 sub last_modification {
195   my ($self) = @_;
196   return $self->mtime // $self->itime;
197 };
198
199 sub used_in_record {
200   my ($self) = @_;
201   die 'not an accessor' if @_ > 1;
202
203   return 1 unless $self->id;
204
205   my @relations = qw(
206     SL::DB::InvoiceItem
207     SL::DB::OrderItem
208     SL::DB::DeliveryOrderItem
209   );
210
211   for my $class (@relations) {
212     eval "require $class";
213     return 1 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
214   }
215   return 0;
216 }
217 sub orphaned {
218   my ($self) = @_;
219   die 'not an accessor' if @_ > 1;
220
221   return 1 unless $self->id;
222
223   my @relations = qw(
224     SL::DB::InvoiceItem
225     SL::DB::OrderItem
226     SL::DB::DeliveryOrderItem
227     SL::DB::Inventory
228     SL::DB::AssortmentItem
229   );
230
231   for my $class (@relations) {
232     eval "require $class";
233     return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
234   }
235   return 1;
236 }
237
238 sub get_sellprice_info {
239   my $self   = shift;
240   my %params = @_;
241
242   confess "Missing part id" unless $self->id;
243
244   my $object = $self->load;
245
246   return { sellprice       => $object->sellprice,
247            price_factor_id => $object->price_factor_id };
248 }
249
250 sub get_ordered_qty {
251   my $self   = shift;
252   my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
253
254   return $result{ $self->id };
255 }
256
257 sub available_units {
258   shift->unit_obj->convertible_units;
259 }
260
261 # autogenerated accessor is slightly off...
262 sub buchungsgruppe {
263   shift->buchungsgruppen(@_);
264 }
265
266 sub get_taxkey {
267   my ($self, %params) = @_;
268
269   my $date     = $params{date} || DateTime->today_local;
270   my $is_sales = !!$params{is_sales};
271   my $taxzone  = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
272   my $tk_info  = $::request->cache('get_taxkey');
273
274   $tk_info->{$self->id}                                      //= {};
275   $tk_info->{$self->id}->{$taxzone}                          //= { };
276   my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
277
278   if (!exists $cache->{$date}) {
279     $cache->{$date} =
280       $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
281       ->get_active_taxkey($date);
282   }
283
284   return $cache->{$date};
285 }
286
287 sub get_chart {
288   my ($self, %params) = @_;
289
290   my $type    = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
291   my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
292
293   my $charts     = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
294   my $all_charts = $::request->cache('get_chart_id/by_id');
295
296   $charts->{$taxzone} ||= { };
297
298   if (!exists $charts->{$taxzone}->{$type}) {
299     require SL::DB::Buchungsgruppe;
300     my $bugru    = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
301     my $chart_id = ($type eq 'inventory') ? ($self->is_part ? $bugru->inventory_accno_id : undef)
302                  :                          $bugru->call_sub("${type}_accno_id", $taxzone);
303
304     if ($chart_id) {
305       my $chart                    = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
306       $all_charts->{$chart_id}     = $chart;
307       $charts->{$taxzone}->{$type} = $chart;
308     }
309   }
310
311   return $charts->{$taxzone}->{$type};
312 }
313
314 sub get_stock {
315   my ($self, %params) = @_;
316
317   return undef unless $self->id;
318
319   my $query = 'SELECT SUM(qty) FROM inventory WHERE parts_id = ?';
320   my @values = ($self->id);
321
322   if ( $params{bin_id} ) {
323     $query .= ' AND bin_id = ?';
324     push(@values, $params{bin_id});
325   }
326
327   if ( $params{warehouse_id} ) {
328     $query .= ' AND warehouse_id = ?';
329     push(@values, $params{warehouse_id});
330   }
331
332   if ( $params{shippingdate} ) {
333     die unless ref($params{shippingdate}) eq 'DateTime';
334     $query .= ' AND shippingdate <= ?';
335     push(@values, $params{shippingdate});
336   }
337
338   my ($stock) = selectrow_query($::form, $self->db->dbh, $query, @values);
339
340   return $stock || 0; # never return undef
341 };
342
343
344 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
345 sub get_simple_stock {
346   my ($self, %params) = @_;
347
348   return [] unless $self->id;
349
350   my $query = <<'';
351     SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
352     GROUP BY warehouse_id, bin_id
353
354   my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
355   [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
356 }
357 # helper class to have bin/warehouse accessors in stock result
358 { package SL::DB::Part::SimpleStock;
359   sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
360   sub bin       { require SL::DB::Bin;       SL::DB::Manager::Bin      ->find_by_or_create(id => $_[0]->{bin_id}) }
361 }
362
363 sub get_simple_stock_sql {
364   my ($self, %params) = @_;
365
366   return [] unless $self->id;
367
368   my $query = <<SQL;
369      SELECT w.description                         AS warehouse_description,
370             b.description                         AS bin_description,
371             SUM(i.qty)                            AS qty,
372             SUM(i.qty * p.lastcost)               AS stock_value,
373             p.unit                                AS unit,
374             LEAD(w.description)           OVER pt AS wh_lead,            -- to detect warehouse changes for subtotals in template
375             SUM( SUM(i.qty) )             OVER pt AS run_qty,            -- running total of total qty
376             SUM( SUM(i.qty) )             OVER wh AS wh_run_qty,         -- running total of warehouse qty
377             SUM( SUM(i.qty * p.lastcost)) OVER pt AS run_stock_value,    -- running total of total stock_value
378             SUM( SUM(i.qty * p.lastcost)) OVER wh AS wh_run_stock_value  -- running total of warehouse stock_value
379        FROM inventory i
380             LEFT JOIN parts p     ON (p.id           = i.parts_id)
381             LEFT JOIN warehouse w ON (i.warehouse_id = w.id)
382             LEFT JOIN bin b       ON (i.bin_id       = b.id)
383       WHERE parts_id = ?
384    GROUP BY w.description, w.sortkey, b.description, p.unit, i.parts_id
385      HAVING SUM(qty) != 0
386      WINDOW pt AS (PARTITION BY i.parts_id    ORDER BY w.sortkey, b.description, p.unit),
387             wh AS (PARTITION by w.description ORDER BY w.sortkey, b.description, p.unit)
388    ORDER BY w.sortkey, b.description, p.unit
389 SQL
390
391   my $stock_info = selectall_hashref_query($::form, $self->db->dbh, $query, $self->id);
392   return $stock_info;
393 }
394
395 sub get_mini_journal {
396   my ($self) = @_;
397
398   # inventory ids of the most recent 10 inventory trans_ids
399
400   # duplicate code copied from SL::Controller::Inventory mini_journal, except
401   # for the added filter on parts_id
402
403   my $parts_id = $self->id;
404   my $query = <<"SQL";
405 with last_inventories as (
406    select id,
407           trans_id,
408           itime
409      from inventory
410     where parts_id = $parts_id
411  order by itime desc
412     limit 20
413 ),
414 grouped_ids as (
415    select trans_id,
416           array_agg(id) as ids
417      from last_inventories
418  group by trans_id
419  order by max(itime)
420      desc limit 10
421 )
422 select unnest(ids)
423   from grouped_ids
424  limit 20  -- so the planner knows how many ids to expect, the cte is an optimisation fence
425 SQL
426
427   my $objs  = SL::DB::Manager::Inventory->get_all(
428     query        => [ id => [ \"$query" ] ],
429     with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
430     sort_by      => 'itime DESC',
431   );
432   # remember order of trans_ids from query, for ordering hash later
433   my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
434
435   # at most 2 of them belong to a transaction and the qty determines in or out.
436   my %transactions;
437   for (@$objs) {
438     $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
439     $transactions{ $_->trans_id }{base} = $_;
440   }
441
442   # because the inventory transactions were built in a hash, we need to sort the
443   # hash by using the original sort order of the trans_ids
444   my @sorted = map { $transactions{$_} } @sorted_trans_ids;
445
446   return \@sorted;
447 }
448
449 sub clone_and_reset_deep {
450   my ($self) = @_;
451
452   my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
453   $clone->makemodels(   map { $_->clone_and_reset } @{$self->makemodels}   ) if @{$self->makemodels};
454   $clone->translations( map { $_->clone_and_reset } @{$self->translations} ) if @{$self->translations};
455
456   if ( $self->is_assortment ) {
457     # use clone rather than reset_and_clone because the unique constraint would also remove parts_id
458     $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
459     $_->assortment_id(undef) foreach @{ $clone->assortment_items }
460   };
461
462   if ( $self->is_assembly ) {
463     $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
464   };
465
466   if ( $self->prices ) {
467     $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
468     if ( $clone->prices ) {
469       foreach my $price ( @{$clone->prices} ) {
470         $price->id(undef);
471         $price->parts_id(undef);
472       };
473     };
474   };
475
476   return $clone;
477 }
478
479 sub item_diffs {
480   my ($self, $comparison_part) = @_;
481
482   die "item_diffs needs a part object" unless ref($comparison_part) eq 'SL::DB::Part';
483   die "part and comparison_part need to be of the same part_type" unless
484         ( $self->part_type eq 'assembly' or $self->part_type eq 'assortment' )
485     and ( $comparison_part->part_type eq 'assembly' or $comparison_part->part_type eq 'assortment' )
486     and $self->part_type eq $comparison_part->part_type;
487
488   # return [], [] if $self->items_checksum eq $comparison_part->items_checksum;
489   my @self_part_ids       = map { $_->parts_id } $self->items;
490   my @comparison_part_ids = map { $_->parts_id } $comparison_part->items;
491
492   my %orig       = map{ $_ => 1 } @self_part_ids;
493   my %comparison = map{ $_ => 1 } @comparison_part_ids;
494   my (@additions, @removals);
495   @additions = grep { !exists( $orig{$_}       ) } @comparison_part_ids if @comparison_part_ids;
496   @removals  = grep { !exists( $comparison{$_} ) } @self_part_ids       if @self_part_ids;
497
498   return \@additions, \@removals;
499 };
500
501 sub items_sellprice_sum {
502   my ($self, %params) = @_;
503
504   return unless $self->is_assortment or $self->is_assembly;
505   return unless $self->items;
506
507   if ($self->is_assembly) {
508     return sum map { $_->linetotal_sellprice          } @{$self->items};
509   } else {
510     return sum map { $_->linetotal_sellprice(%params) } grep { $_->charge } @{$self->items};
511   }
512 }
513
514 sub items_lastcost_sum {
515   my ($self) = @_;
516
517   return unless $self->is_assortment or $self->is_assembly;
518   return unless $self->items;
519   sum map { $_->linetotal_lastcost } @{$self->items};
520 };
521
522 1;
523
524 __END__
525
526 =pod
527
528 =encoding utf-8
529
530 =head1 NAME
531
532 SL::DB::Part: Model for the 'parts' table
533
534 =head1 SYNOPSIS
535
536 This is a standard Rose::DB::Object based model and can be used as one.
537
538 =head1 TYPES
539
540 Although the base class is called C<Part> we usually talk about C<Articles> if
541 we mean instances of this class. This is because articles come in three
542 flavours called:
543
544 =over 4
545
546 =item Part     - a single part
547
548 =item Service  - a part without onhand, and without inventory accounting
549
550 =item Assembly - a collection of both parts and services
551
552 =item Assortment - a collection of items (parts or assemblies)
553
554 =back
555
556 These types are sadly represented by data inside the class and cannot be
557 migrated into a flag. To work around this, each C<Part> object knows what type
558 it currently is. Since the type is data driven, there ist no explicit setting
559 method for it, but you can construct them explicitly with C<new_part>,
560 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
561 case, but it will use the default Buchungsgruppe if you don't.
562
563 Matching these there are assorted helper methods dealing with types,
564 e.g.  L</new_part>, L</new_service>, L</new_assembly>, L</type>,
565 L</is_type> and others.
566
567 =head1 FUNCTIONS
568
569 =over 4
570
571 =item C<new_part %PARAMS>
572
573 =item C<new_service %PARAMS>
574
575 =item C<new_assembly %PARAMS>
576
577 Will set the appropriate data fields so that the resulting instance will be of
578 the requested type. Since accounting targets are part of the distinction,
579 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
580 will load a default one and set the accounting targets from it.
581
582 =item C<type>
583
584 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
585
586 =item C<is_type $TYPE>
587
588 Tests if the current object is a part, a service or an
589 assembly. C<$type> must be one of the words 'part', 'service' or
590 'assembly' (their plurals are ok, too).
591
592 Returns 1 if the requested type matches, 0 if it doesn't and
593 C<confess>es if an unknown C<$type> parameter is encountered.
594
595 =item C<is_part>
596
597 =item C<is_service>
598
599 =item C<is_assembly>
600
601 Shorthand for C<is_type('part')> etc.
602
603 =item C<get_sellprice_info %params>
604
605 Retrieves the C<sellprice> and C<price_factor_id> for a part under
606 different conditions and returns a hash reference with those two keys.
607
608 If C<%params> contains a key C<project_id> then a project price list
609 will be consulted if one exists for that project. In this case the
610 parameter C<country_id> is evaluated as well: if a price list entry
611 has been created for this country then it will be used. Otherwise an
612 entry without a country set will be used.
613
614 If none of the above conditions is met then the information from
615 C<$self> is used.
616
617 =item C<get_ordered_qty %params>
618
619 Retrieves the quantity that has been ordered from a vendor but that
620 has not been delivered yet. Only open purchase orders are considered.
621
622 =item C<get_taxkey %params>
623
624 Retrieves and returns a taxkey object valid for the given date
625 C<$params{date}> and tax zone C<$params{taxzone}>
626 (C<$params{taxzone_id}> is also recognized). The date defaults to the
627 current date if undefined.
628
629 This function looks up the income (for trueish values of
630 C<$params{is_sales}>) or expense (for falsish values of
631 C<$params{is_sales}>) account for the current part. It uses the part's
632 associated buchungsgruppe and uses the fields belonging to the tax
633 zone given by C<$params{taxzone}>.
634
635 The information retrieved by the function is cached.
636
637 =item C<get_chart %params>
638
639 Retrieves and returns a chart object valid for the given type
640 C<$params{type}> and tax zone C<$params{taxzone}>
641 (C<$params{taxzone_id}> is also recognized). The type must be one of
642 the three key words C<income>, C<expense> and C<inventory>.
643
644 This function uses the part's associated buchungsgruppe and uses the
645 fields belonging to the tax zone given by C<$params{taxzone}>.
646
647 The information retrieved by the function is cached.
648
649 =item C<used_in_record>
650
651 Checks if this article has been used in orders, invoices or delivery orders.
652
653 =item C<orphaned>
654
655 Checks if this article is used in orders, invoices, delivery orders or
656 assemblies.
657
658 =item C<buchungsgruppe BUCHUNGSGRUPPE>
659
660 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
661 Please note, that this is a write only accessor, the original Buchungsgruppe can
662 not be retrieved from an article once set.
663
664 =item C<get_simple_stock_sql>
665
666 Fetches the qty and the stock value for the current part for each bin and
667 warehouse where the part is in stock (or rather different from 0, might be
668 negative).
669
670 Runs some additional window functions to add the running totals (total running
671 total and total per warehouse) for qty and stock value to each line.
672
673 Using the LEAD(w.description) the template can check if the warehouse
674 description is about to change, i.e. the next line will contain numbers from a
675 different warehouse, so that a subtotal line can be added.
676
677 The last row will contain the running qty total (run_qty) and the running total
678 stock value (run_stock_value) over all warehouses/bins and can be used to add a
679 line for the grand totals.
680
681 =item C<items_lastcost_sum>
682
683 Non-recursive lastcost sum of all the items in an assembly or assortment.
684
685 =item C<get_stock %params>
686
687 Fetches stock qty in the default unit for a part.
688
689 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
690 the stock qty for that bin is returned. If only a warehouse_id is passed, the
691 stock qty for all bins in that warehouse is returned.  If a shippingdate is
692 passed the stock qty for that date is returned.
693
694 Examples:
695  my $qty = $part->get_stock(bin_id => 52);
696
697  $part->get_stock(shippingdate => DateTime->today->add(days => -5));
698
699 =back
700
701 =head1 AUTHORS
702
703 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
704 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
705
706 =cut