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