Tabelle assembly bekommt eine Positionsspalte
[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 1;
251
252 __END__
253
254 =pod
255
256 =encoding utf-8
257
258 =head1 NAME
259
260 SL::DB::Part: Model for the 'parts' table
261
262 =head1 SYNOPSIS
263
264 This is a standard Rose::DB::Object based model and can be used as one.
265
266 =head1 TYPES
267
268 Although the base class is called C<Part> we usually talk about C<Articles> if
269 we mean instances of this class. This is because articles come in three
270 flavours called:
271
272 =over 4
273
274 =item Part     - a single part
275
276 =item Service  - a part without onhand, and without inventory accounting
277
278 =item Assembly - a collection of both parts and services
279
280 =item Assortment - a collection of parts
281
282 =back
283
284 These types are sadly represented by data inside the class and cannot be
285 migrated into a flag. To work around this, each C<Part> object knows what type
286 it currently is. Since the type is data driven, there ist no explicit setting
287 method for it, but you can construct them explicitly with C<new_part>,
288 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
289 case, but it will use the default Buchungsgruppe if you don't.
290
291 Matching these there are assorted helper methods dealing with types,
292 e.g.  L</new_part>, L</new_service>, L</new_assembly>, L</type>,
293 L</is_type> and others.
294
295 =head1 FUNCTIONS
296
297 =over 4
298
299 =item C<new_part %PARAMS>
300
301 =item C<new_service %PARAMS>
302
303 =item C<new_assembly %PARAMS>
304
305 Will set the appropriate data fields so that the resulting instance will be of
306 the requested type. Since accounting targets are part of the distinction,
307 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
308 will load a default one and set the accounting targets from it.
309
310 =item C<type>
311
312 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
313
314 =item C<is_type $TYPE>
315
316 Tests if the current object is a part, a service or an
317 assembly. C<$type> must be one of the words 'part', 'service' or
318 'assembly' (their plurals are ok, too).
319
320 Returns 1 if the requested type matches, 0 if it doesn't and
321 C<confess>es if an unknown C<$type> parameter is encountered.
322
323 =item C<is_part>
324
325 =item C<is_service>
326
327 =item C<is_assembly>
328
329 Shorthand for C<is_type('part')> etc.
330
331 =item C<get_sellprice_info %params>
332
333 Retrieves the C<sellprice> and C<price_factor_id> for a part under
334 different conditions and returns a hash reference with those two keys.
335
336 If C<%params> contains a key C<project_id> then a project price list
337 will be consulted if one exists for that project. In this case the
338 parameter C<country_id> is evaluated as well: if a price list entry
339 has been created for this country then it will be used. Otherwise an
340 entry without a country set will be used.
341
342 If none of the above conditions is met then the information from
343 C<$self> is used.
344
345 =item C<get_ordered_qty %params>
346
347 Retrieves the quantity that has been ordered from a vendor but that
348 has not been delivered yet. Only open purchase orders are considered.
349
350 =item C<get_taxkey %params>
351
352 Retrieves and returns a taxkey object valid for the given date
353 C<$params{date}> and tax zone C<$params{taxzone}>
354 (C<$params{taxzone_id}> is also recognized). The date defaults to the
355 current date if undefined.
356
357 This function looks up the income (for trueish values of
358 C<$params{is_sales}>) or expense (for falsish values of
359 C<$params{is_sales}>) account for the current part. It uses the part's
360 associated buchungsgruppe and uses the fields belonging to the tax
361 zone given by C<$params{taxzone}>.
362
363 The information retrieved by the function is cached.
364
365 =item C<get_chart %params>
366
367 Retrieves and returns a chart object valid for the given type
368 C<$params{type}> and tax zone C<$params{taxzone}>
369 (C<$params{taxzone_id}> is also recognized). The type must be one of
370 the three key words C<income>, C<expense> and C<inventory>.
371
372 This function uses the part's associated buchungsgruppe and uses the
373 fields belonging to the tax zone given by C<$params{taxzone}>.
374
375 The information retrieved by the function is cached.
376
377 =item C<orphaned>
378
379 Checks if this article is used in orders, invoices, delivery orders or
380 assemblies.
381
382 =item C<buchungsgruppe BUCHUNGSGRUPPE>
383
384 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
385 Please note, that this is a write only accessor, the original Buchungsgruppe can
386 not be retrieved from an article once set.
387
388 =back
389
390 =head1 AUTHORS
391
392 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
393 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
394
395 =cut