d19ec26e4eac2c3ddb92ec7ad425c0a84e004228
[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);
7 use Rose::DB::Object::Helpers qw(as_tree);
8
9 use SL::DBUtils;
10 use SL::DB::MetaSetup::Part;
11 use SL::DB::Manager::Part;
12 use SL::DB::Chart;
13 use SL::DB::Helper::AttrHTML;
14 use SL::DB::Helper::TransNumberGenerator;
15 use SL::DB::Helper::CustomVariables (
16   module      => 'IC',
17   cvars_alias => 1,
18 );
19
20 __PACKAGE__->meta->add_relationships(
21   assemblies                     => {
22     type         => 'one to many',
23     class        => 'SL::DB::Assembly',
24     manager_args => { sort_by => 'position, oid' },
25     column_map   => { id => 'id' },
26   },
27   prices         => {
28     type         => 'one to many',
29     class        => 'SL::DB::Price',
30     column_map   => { id => 'parts_id' },
31   },
32   makemodels     => {
33     type         => 'one to many',
34     class        => 'SL::DB::MakeModel',
35     manager_args => { sort_by => 'sortorder' },
36     column_map   => { id => 'parts_id' },
37   },
38   translations   => {
39     type         => 'one to many',
40     class        => 'SL::DB::Translation',
41     column_map   => { id => 'parts_id' },
42   },
43   assortment_items => {
44     type         => 'one to many',
45     class        => 'SL::DB::AssortmentItem',
46     column_map   => { id => 'assortment_id' },
47   },
48 );
49
50 __PACKAGE__->meta->initialize;
51
52 __PACKAGE__->attr_html('notes');
53
54 __PACKAGE__->before_save('_before_save_set_partnumber');
55
56 sub _before_save_set_partnumber {
57   my ($self) = @_;
58
59   $self->create_trans_number if !$self->partnumber;
60   return 1;
61 }
62
63 sub validate {
64   my ($self) = @_;
65
66   my @errors;
67   push @errors, $::locale->text('The partnumber is missing.')     unless $self->partnumber;
68   push @errors, $::locale->text('The unit is missing.')           unless $self->unit;
69   push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
70
71   unless ( $self->id ) {
72     push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
73   };
74
75   if ($self->is_assortment && scalar @{$self->assortment_items} == 0) {
76     push @errors, $::locale->text('The assortment doesn\'t have any items.');
77   }
78
79   if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
80     push @errors, $::locale->text('The assembly doesn\'t have any items.');
81   }
82
83   return @errors;
84 }
85
86 sub is_type {
87   my $self = shift;
88   my $type  = lc(shift || '');
89   die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
90
91   return $self->type eq $type ? 1 : 0;
92 }
93
94 sub is_part       { $_[0]->part_type eq 'part'       }
95 sub is_assembly   { $_[0]->part_type eq 'assembly'   }
96 sub is_service    { $_[0]->part_type eq 'service'    }
97 sub is_assortment { $_[0]->part_type eq 'assortment' }
98
99 sub type {
100   return $_[0]->part_type;
101   # my ($self, $type) = @_;
102   # if (@_ > 1) {
103   #   die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
104   #   $self->assembly(          $type eq 'assembly' ? 1 : 0);
105   #   $self->inventory_accno_id($type ne 'service'  ? 1 : undef);
106   # }
107
108   # return 'assembly' if $self->assembly;
109   # return 'part'     if $self->inventory_accno_id;
110   # return 'service';
111 }
112
113 sub new_part {
114   my ($class, %params) = @_;
115   $class->new(%params, part_type => 'part');
116 }
117
118 sub new_assembly {
119   my ($class, %params) = @_;
120   $class->new(%params, part_type => 'assembly');
121 }
122
123 sub new_service {
124   my ($class, %params) = @_;
125   $class->new(%params, part_type => 'service');
126 }
127
128 sub new_assortment {
129   my ($class, %params) = @_;
130   $class->new(%params, part_type => 'assortment');
131 }
132
133 sub last_modification {
134   my ($self) = @_;
135   return $self->mtime or $self->itime;
136 };
137
138 sub orphaned {
139   my ($self) = @_;
140   die 'not an accessor' if @_ > 1;
141
142   my @relations = qw(
143     SL::DB::InvoiceItem
144     SL::DB::OrderItem
145     SL::DB::Inventory
146     SL::DB::Assembly
147     SL::DB::AssortmentItem
148   );
149
150   for my $class (@relations) {
151     eval "require $class";
152     return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
153   }
154   return 1;
155 }
156
157 sub get_sellprice_info {
158   my $self   = shift;
159   my %params = @_;
160
161   confess "Missing part id" unless $self->id;
162
163   my $object = $self->load;
164
165   return { sellprice       => $object->sellprice,
166            price_factor_id => $object->price_factor_id };
167 }
168
169 sub get_ordered_qty {
170   my $self   = shift;
171   my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
172
173   return $result{ $self->id };
174 }
175
176 sub available_units {
177   shift->unit_obj->convertible_units;
178 }
179
180 # autogenerated accessor is slightly off...
181 sub buchungsgruppe {
182   shift->buchungsgruppen(@_);
183 }
184
185 sub get_taxkey {
186   my ($self, %params) = @_;
187
188   my $date     = $params{date} || DateTime->today_local;
189   my $is_sales = !!$params{is_sales};
190   my $taxzone  = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
191   my $tk_info  = $::request->cache('get_taxkey');
192
193   $tk_info->{$self->id}                                      //= {};
194   $tk_info->{$self->id}->{$taxzone}                          //= { };
195   my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
196
197   if (!exists $cache->{$date}) {
198     $cache->{$date} =
199       $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
200       ->get_active_taxkey($date);
201   }
202
203   return $cache->{$date};
204 }
205
206 sub get_chart {
207   my ($self, %params) = @_;
208
209   my $type    = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
210   my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
211
212   my $charts     = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
213   my $all_charts = $::request->cache('get_chart_id/by_id');
214
215   $charts->{$taxzone} ||= { };
216
217   if (!exists $charts->{$taxzone}->{$type}) {
218     require SL::DB::Buchungsgruppe;
219     my $bugru    = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
220     my $chart_id = ($type eq 'inventory') ? ($self->inventory_accno_id ? $bugru->inventory_accno_id : undef)
221                  :                          $bugru->call_sub("${type}_accno_id", $taxzone);
222
223     if ($chart_id) {
224       my $chart                    = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
225       $all_charts->{$chart_id}     = $chart;
226       $charts->{$taxzone}->{$type} = $chart;
227     }
228   }
229
230   return $charts->{$taxzone}->{$type};
231 }
232
233 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
234 sub get_simple_stock {
235   my ($self, %params) = @_;
236
237   return [] unless $self->id;
238
239   my $query = <<'';
240     SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
241     GROUP BY warehouse_id, bin_id
242
243   my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
244   [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
245 }
246 # helper class to have bin/warehouse accessors in stock result
247 { package SL::DB::Part::SimpleStock;
248   sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
249   sub bin       { require SL::DB::Bin;       SL::DB::Manager::Bin      ->find_by_or_create(id => $_[0]->{bin_id}) }
250 }
251
252 sub displayable_name {
253   join ' ', grep $_, map $_[0]->$_, qw(partnumber description);
254 }
255
256 sub clone_and_reset_deep {
257   my ($self) = @_;
258
259   my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
260   $clone->makemodels(       map { $_->clone_and_reset } @{$self->makemodels});
261   $clone->translations(     map { $_->clone_and_reset } @{$self->translations});
262
263   if ( $self->is_assortment ) {
264     $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
265       foreach my $ai ( @{ $clone->assortment_items } ) {
266         $ai->assortment_id(undef);
267       };
268   };
269
270   if ( $self->is_assembly ) {
271     $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
272   };
273
274   if ( $self->prices ) {
275     $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
276     if ( $clone->prices ) {
277       foreach my $price ( @{$clone->prices} ) {
278         $price->id(undef);
279         $price->parts_id(undef);
280       };
281     };
282   };
283
284   return $clone;
285 }
286
287 1;
288
289 __END__
290
291 =pod
292
293 =encoding utf-8
294
295 =head1 NAME
296
297 SL::DB::Part: Model for the 'parts' table
298
299 =head1 SYNOPSIS
300
301 This is a standard Rose::DB::Object based model and can be used as one.
302
303 =head1 TYPES
304
305 Although the base class is called C<Part> we usually talk about C<Articles> if
306 we mean instances of this class. This is because articles come in three
307 flavours called:
308
309 =over 4
310
311 =item Part     - a single part
312
313 =item Service  - a part without onhand, and without inventory accounting
314
315 =item Assembly - a collection of both parts and services
316
317 =item Assortment - a collection of parts
318
319 =back
320
321 These types are sadly represented by data inside the class and cannot be
322 migrated into a flag. To work around this, each C<Part> object knows what type
323 it currently is. Since the type is data driven, there ist no explicit setting
324 method for it, but you can construct them explicitly with C<new_part>,
325 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
326 case, but it will use the default Buchungsgruppe if you don't.
327
328 Matching these there are assorted helper methods dealing with types,
329 e.g.  L</new_part>, L</new_service>, L</new_assembly>, L</type>,
330 L</is_type> and others.
331
332 =head1 FUNCTIONS
333
334 =over 4
335
336 =item C<new_part %PARAMS>
337
338 =item C<new_service %PARAMS>
339
340 =item C<new_assembly %PARAMS>
341
342 Will set the appropriate data fields so that the resulting instance will be of
343 the requested type. Since accounting targets are part of the distinction,
344 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
345 will load a default one and set the accounting targets from it.
346
347 =item C<type>
348
349 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
350
351 =item C<is_type $TYPE>
352
353 Tests if the current object is a part, a service or an
354 assembly. C<$type> must be one of the words 'part', 'service' or
355 'assembly' (their plurals are ok, too).
356
357 Returns 1 if the requested type matches, 0 if it doesn't and
358 C<confess>es if an unknown C<$type> parameter is encountered.
359
360 =item C<is_part>
361
362 =item C<is_service>
363
364 =item C<is_assembly>
365
366 Shorthand for C<is_type('part')> etc.
367
368 =item C<get_sellprice_info %params>
369
370 Retrieves the C<sellprice> and C<price_factor_id> for a part under
371 different conditions and returns a hash reference with those two keys.
372
373 If C<%params> contains a key C<project_id> then a project price list
374 will be consulted if one exists for that project. In this case the
375 parameter C<country_id> is evaluated as well: if a price list entry
376 has been created for this country then it will be used. Otherwise an
377 entry without a country set will be used.
378
379 If none of the above conditions is met then the information from
380 C<$self> is used.
381
382 =item C<get_ordered_qty %params>
383
384 Retrieves the quantity that has been ordered from a vendor but that
385 has not been delivered yet. Only open purchase orders are considered.
386
387 =item C<get_taxkey %params>
388
389 Retrieves and returns a taxkey object valid for the given date
390 C<$params{date}> and tax zone C<$params{taxzone}>
391 (C<$params{taxzone_id}> is also recognized). The date defaults to the
392 current date if undefined.
393
394 This function looks up the income (for trueish values of
395 C<$params{is_sales}>) or expense (for falsish values of
396 C<$params{is_sales}>) account for the current part. It uses the part's
397 associated buchungsgruppe and uses the fields belonging to the tax
398 zone given by C<$params{taxzone}>.
399
400 The information retrieved by the function is cached.
401
402 =item C<get_chart %params>
403
404 Retrieves and returns a chart object valid for the given type
405 C<$params{type}> and tax zone C<$params{taxzone}>
406 (C<$params{taxzone_id}> is also recognized). The type must be one of
407 the three key words C<income>, C<expense> and C<inventory>.
408
409 This function uses the part's associated buchungsgruppe and uses the
410 fields belonging to the tax zone given by C<$params{taxzone}>.
411
412 The information retrieved by the function is cached.
413
414 =item C<orphaned>
415
416 Checks if this article is used in orders, invoices, delivery orders or
417 assemblies.
418
419 =item C<buchungsgruppe BUCHUNGSGRUPPE>
420
421 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
422 Please note, that this is a write only accessor, the original Buchungsgruppe can
423 not be retrieved from an article once set.
424
425 =back
426
427 =head1 AUTHORS
428
429 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
430 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
431
432 =cut