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