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