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