6 use List::MoreUtils qw(any);
7 use Rose::DB::Object::Helpers qw(as_tree);
10 use SL::DB::MetaSetup::Part;
11 use SL::DB::Manager::Part;
13 use SL::DB::Helper::AttrHTML;
14 use SL::DB::Helper::TransNumberGenerator;
15 use SL::DB::Helper::CustomVariables (
19 use List::Util qw(sum);
21 __PACKAGE__->meta->add_relationships(
23 type => 'one to many',
24 class => 'SL::DB::Assembly',
25 manager_args => { sort_by => 'position, oid' },
26 column_map => { id => 'id' },
29 type => 'one to many',
30 class => 'SL::DB::Price',
31 column_map => { id => 'parts_id' },
32 manager_args => { with_objects => [ 'pricegroup' ] }
35 type => 'one to many',
36 class => 'SL::DB::MakeModel',
37 manager_args => { sort_by => 'sortorder' },
38 column_map => { id => 'parts_id' },
41 type => 'one to many',
42 class => 'SL::DB::Translation',
43 column_map => { id => 'parts_id' },
46 type => 'one to many',
47 class => 'SL::DB::AssortmentItem',
48 column_map => { id => 'assortment_id' },
51 type => 'one to many',
52 class => 'SL::DB::History',
53 column_map => { id => 'trans_id' },
54 query_args => [ what_done => 'part' ],
55 manager_args => { sort_by => 'itime' },
58 type => 'one to many',
59 class => 'SL::DB::ShopPart',
60 column_map => { id => 'part_id' },
61 manager_args => { with_objects => [ 'shop' ] },
65 __PACKAGE__->meta->initialize;
67 __PACKAGE__->attr_html('notes');
69 __PACKAGE__->before_save('_before_save_set_partnumber');
71 sub _before_save_set_partnumber {
74 $self->create_trans_number if !$self->partnumber;
81 if ( $self->part_type eq 'assembly' ) {
82 return $self->assemblies;
83 } elsif ( $self->part_type eq 'assortment' ) {
84 return $self->assortment_items;
93 # for detecting if the items of an (orphaned) assembly or assortment have
96 return join(' ', sort map { $_->part->id } @{$self->items});
103 push @errors, $::locale->text('The partnumber is missing.') if $self->id and !$self->partnumber;
104 push @errors, $::locale->text('The unit is missing.') unless $self->unit;
105 push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
107 unless ( $self->id ) {
108 push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
111 if ($self->is_assortment && $self->orphaned && scalar @{$self->assortment_items} == 0) {
112 # when assortment isn't orphaned form doesn't contain any items
113 push @errors, $::locale->text('The assortment doesn\'t have any items.');
116 if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
117 push @errors, $::locale->text('The assembly doesn\'t have any items.');
125 my $type = lc(shift || '');
126 die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
128 return $self->type eq $type ? 1 : 0;
131 sub is_part { $_[0]->part_type eq 'part' }
132 sub is_assembly { $_[0]->part_type eq 'assembly' }
133 sub is_service { $_[0]->part_type eq 'service' }
134 sub is_assortment { $_[0]->part_type eq 'assortment' }
137 return $_[0]->part_type;
138 # my ($self, $type) = @_;
140 # die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
141 # $self->assembly( $type eq 'assembly' ? 1 : 0);
142 # $self->inventory_accno_id($type ne 'service' ? 1 : undef);
145 # return 'assembly' if $self->assembly;
146 # return 'part' if $self->inventory_accno_id;
151 my ($class, %params) = @_;
152 $class->new(%params, part_type => 'part');
156 my ($class, %params) = @_;
157 $class->new(%params, part_type => 'assembly');
161 my ($class, %params) = @_;
162 $class->new(%params, part_type => 'service');
166 my ($class, %params) = @_;
167 $class->new(%params, part_type => 'assortment');
170 sub last_modification {
172 return $self->mtime // $self->itime;
177 die 'not an accessor' if @_ > 1;
179 return 1 unless $self->id;
184 SL::DB::DeliveryOrderItem
187 for my $class (@relations) {
188 eval "require $class";
189 return 1 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
195 die 'not an accessor' if @_ > 1;
197 return 1 unless $self->id;
202 SL::DB::DeliveryOrderItem
204 SL::DB::AssortmentItem
207 for my $class (@relations) {
208 eval "require $class";
209 return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
214 sub get_sellprice_info {
218 confess "Missing part id" unless $self->id;
220 my $object = $self->load;
222 return { sellprice => $object->sellprice,
223 price_factor_id => $object->price_factor_id };
226 sub get_ordered_qty {
228 my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
230 return $result{ $self->id };
233 sub available_units {
234 shift->unit_obj->convertible_units;
237 # autogenerated accessor is slightly off...
239 shift->buchungsgruppen(@_);
243 my ($self, %params) = @_;
245 my $date = $params{date} || DateTime->today_local;
246 my $is_sales = !!$params{is_sales};
247 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
248 my $tk_info = $::request->cache('get_taxkey');
250 $tk_info->{$self->id} //= {};
251 $tk_info->{$self->id}->{$taxzone} //= { };
252 my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
254 if (!exists $cache->{$date}) {
256 $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
257 ->get_active_taxkey($date);
260 return $cache->{$date};
264 my ($self, %params) = @_;
266 my $type = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
267 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
269 my $charts = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
270 my $all_charts = $::request->cache('get_chart_id/by_id');
272 $charts->{$taxzone} ||= { };
274 if (!exists $charts->{$taxzone}->{$type}) {
275 require SL::DB::Buchungsgruppe;
276 my $bugru = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
277 my $chart_id = ($type eq 'inventory') ? ($self->is_part ? $bugru->inventory_accno_id : undef)
278 : $bugru->call_sub("${type}_accno_id", $taxzone);
281 my $chart = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
282 $all_charts->{$chart_id} = $chart;
283 $charts->{$taxzone}->{$type} = $chart;
287 return $charts->{$taxzone}->{$type};
291 my ($self, %params) = @_;
293 return undef unless $self->id;
295 my $query = 'SELECT SUM(qty) FROM inventory WHERE parts_id = ?';
296 my @values = ($self->id);
298 if ( $params{bin_id} ) {
299 $query .= ' AND bin_id = ?';
300 push(@values, $params{bin_id});
303 if ( $params{warehouse_id} ) {
304 $query .= ' AND warehouse_id = ?';
305 push(@values, $params{warehouse_id});
308 if ( $params{shippingdate} ) {
309 die unless ref($params{shippingdate}) eq 'DateTime';
310 $query .= ' AND shippingdate <= ?';
311 push(@values, $params{shippingdate});
314 my ($stock) = selectrow_query($::form, $self->db->dbh, $query, @values);
316 return $stock || 0; # never return undef
320 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
321 sub get_simple_stock {
322 my ($self, %params) = @_;
324 return [] unless $self->id;
327 SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
328 GROUP BY warehouse_id, bin_id
330 my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
331 [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
333 # helper class to have bin/warehouse accessors in stock result
334 { package SL::DB::Part::SimpleStock;
335 sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
336 sub bin { require SL::DB::Bin; SL::DB::Manager::Bin ->find_by_or_create(id => $_[0]->{bin_id}) }
339 sub displayable_name {
340 join ' ', grep $_, map $_[0]->$_, qw(partnumber description);
343 sub clone_and_reset_deep {
346 my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
347 $clone->makemodels( map { $_->clone_and_reset } @{$self->makemodels} ) if @{$self->makemodels};
348 $clone->translations( map { $_->clone_and_reset } @{$self->translations} ) if @{$self->translations};
350 if ( $self->is_assortment ) {
351 # use clone rather than reset_and_clone because the unique constraint would also remove parts_id
352 $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
353 $_->assortment_id(undef) foreach @{ $clone->assortment_items }
356 if ( $self->is_assembly ) {
357 $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
360 if ( $self->prices ) {
361 $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
362 if ( $clone->prices ) {
363 foreach my $price ( @{$clone->prices} ) {
365 $price->parts_id(undef);
374 my ($self, $comparison_part) = @_;
376 die "item_diffs needs a part object" unless ref($comparison_part) eq 'SL::DB::Part';
377 die "part and comparison_part need to be of the same part_type" unless
378 ( $self->part_type eq 'assembly' or $self->part_type eq 'assortment' )
379 and ( $comparison_part->part_type eq 'assembly' or $comparison_part->part_type eq 'assortment' )
380 and $self->part_type eq $comparison_part->part_type;
382 # return [], [] if $self->items_checksum eq $comparison_part->items_checksum;
383 my @self_part_ids = map { $_->parts_id } $self->items;
384 my @comparison_part_ids = map { $_->parts_id } $comparison_part->items;
386 my %orig = map{ $_ => 1 } @self_part_ids;
387 my %comparison = map{ $_ => 1 } @comparison_part_ids;
388 my (@additions, @removals);
389 @additions = grep { !exists( $orig{$_} ) } @comparison_part_ids if @comparison_part_ids;
390 @removals = grep { !exists( $comparison{$_} ) } @self_part_ids if @self_part_ids;
392 return \@additions, \@removals;
395 sub items_sellprice_sum {
396 my ($self, %params) = @_;
398 return unless $self->is_assortment or $self->is_assembly;
399 return unless $self->items;
401 if ($self->is_assembly) {
402 return sum map { $_->linetotal_sellprice } @{$self->items};
404 return sum map { $_->linetotal_sellprice(%params) } grep { $_->charge } @{$self->items};
408 sub items_lastcost_sum {
411 return unless $self->is_assortment or $self->is_assembly;
412 return unless $self->items;
413 sum map { $_->linetotal_lastcost } @{$self->items};
416 sub assortment_lastcost_sum {
419 return unless $self->is_assortment;
420 sum map { $_->linetotal_lastcost } @{$self->assortment_items};
433 SL::DB::Part: Model for the 'parts' table
437 This is a standard Rose::DB::Object based model and can be used as one.
441 Although the base class is called C<Part> we usually talk about C<Articles> if
442 we mean instances of this class. This is because articles come in three
447 =item Part - a single part
449 =item Service - a part without onhand, and without inventory accounting
451 =item Assembly - a collection of both parts and services
453 =item Assortment - a collection of items (parts or assemblies)
457 These types are sadly represented by data inside the class and cannot be
458 migrated into a flag. To work around this, each C<Part> object knows what type
459 it currently is. Since the type is data driven, there ist no explicit setting
460 method for it, but you can construct them explicitly with C<new_part>,
461 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
462 case, but it will use the default Buchungsgruppe if you don't.
464 Matching these there are assorted helper methods dealing with types,
465 e.g. L</new_part>, L</new_service>, L</new_assembly>, L</type>,
466 L</is_type> and others.
472 =item C<new_part %PARAMS>
474 =item C<new_service %PARAMS>
476 =item C<new_assembly %PARAMS>
478 Will set the appropriate data fields so that the resulting instance will be of
479 the requested type. Since accounting targets are part of the distinction,
480 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
481 will load a default one and set the accounting targets from it.
485 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
487 =item C<is_type $TYPE>
489 Tests if the current object is a part, a service or an
490 assembly. C<$type> must be one of the words 'part', 'service' or
491 'assembly' (their plurals are ok, too).
493 Returns 1 if the requested type matches, 0 if it doesn't and
494 C<confess>es if an unknown C<$type> parameter is encountered.
502 Shorthand for C<is_type('part')> etc.
504 =item C<get_sellprice_info %params>
506 Retrieves the C<sellprice> and C<price_factor_id> for a part under
507 different conditions and returns a hash reference with those two keys.
509 If C<%params> contains a key C<project_id> then a project price list
510 will be consulted if one exists for that project. In this case the
511 parameter C<country_id> is evaluated as well: if a price list entry
512 has been created for this country then it will be used. Otherwise an
513 entry without a country set will be used.
515 If none of the above conditions is met then the information from
518 =item C<get_ordered_qty %params>
520 Retrieves the quantity that has been ordered from a vendor but that
521 has not been delivered yet. Only open purchase orders are considered.
523 =item C<get_taxkey %params>
525 Retrieves and returns a taxkey object valid for the given date
526 C<$params{date}> and tax zone C<$params{taxzone}>
527 (C<$params{taxzone_id}> is also recognized). The date defaults to the
528 current date if undefined.
530 This function looks up the income (for trueish values of
531 C<$params{is_sales}>) or expense (for falsish values of
532 C<$params{is_sales}>) account for the current part. It uses the part's
533 associated buchungsgruppe and uses the fields belonging to the tax
534 zone given by C<$params{taxzone}>.
536 The information retrieved by the function is cached.
538 =item C<get_chart %params>
540 Retrieves and returns a chart object valid for the given type
541 C<$params{type}> and tax zone C<$params{taxzone}>
542 (C<$params{taxzone_id}> is also recognized). The type must be one of
543 the three key words C<income>, C<expense> and C<inventory>.
545 This function uses the part's associated buchungsgruppe and uses the
546 fields belonging to the tax zone given by C<$params{taxzone}>.
548 The information retrieved by the function is cached.
550 =item C<used_in_record>
552 Checks if this article has been used in orders, invoices or delivery orders.
556 Checks if this article is used in orders, invoices, delivery orders or
559 =item C<buchungsgruppe BUCHUNGSGRUPPE>
561 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
562 Please note, that this is a write only accessor, the original Buchungsgruppe can
563 not be retrieved from an article once set.
565 =item C<assembly_sellprice_sum>
567 Non-recursive sellprice sum of all the assembly item sellprices.
569 =item C<assortment_sellprice_sum>
571 Non-recursive sellprice sum of all the assortment item sellprices.
573 =item C<assembly_lastcost_sum>
575 Non-recursive lastcost sum of all the assembly item lastcosts.
577 =item C<assortment_lastcost_sum>
579 Non-recursive lastcost sum of all the assortment item lastcosts.
581 =item C<get_stock %params>
583 Fetches stock qty in the default unit for a part.
585 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
586 the stock qty for that bin is returned. If only a warehouse_id is passed, the
587 stock qty for all bins in that warehouse is returned. If a shippingdate is
588 passed the stock qty for that date is returned.
591 my $qty = $part->get_stock(bin_id => 52);
593 $part->get_stock(shippingdate => DateTime->today->add(days => -5));
599 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
600 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>