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