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