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};
 
 426 SL::DB::Part: Model for the 'parts' table
 
 430 This is a standard Rose::DB::Object based model and can be used as one.
 
 434 Although the base class is called C<Part> we usually talk about C<Articles> if
 
 435 we mean instances of this class. This is because articles come in three
 
 440 =item Part     - a single part
 
 442 =item Service  - a part without onhand, and without inventory accounting
 
 444 =item Assembly - a collection of both parts and services
 
 446 =item Assortment - a collection of items (parts or assemblies)
 
 450 These types are sadly represented by data inside the class and cannot be
 
 451 migrated into a flag. To work around this, each C<Part> object knows what type
 
 452 it currently is. Since the type is data driven, there ist no explicit setting
 
 453 method for it, but you can construct them explicitly with C<new_part>,
 
 454 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
 
 455 case, but it will use the default Buchungsgruppe if you don't.
 
 457 Matching these there are assorted helper methods dealing with types,
 
 458 e.g.  L</new_part>, L</new_service>, L</new_assembly>, L</type>,
 
 459 L</is_type> and others.
 
 465 =item C<new_part %PARAMS>
 
 467 =item C<new_service %PARAMS>
 
 469 =item C<new_assembly %PARAMS>
 
 471 Will set the appropriate data fields so that the resulting instance will be of
 
 472 the requested type. Since accounting targets are part of the distinction,
 
 473 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
 
 474 will load a default one and set the accounting targets from it.
 
 478 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
 
 480 =item C<is_type $TYPE>
 
 482 Tests if the current object is a part, a service or an
 
 483 assembly. C<$type> must be one of the words 'part', 'service' or
 
 484 'assembly' (their plurals are ok, too).
 
 486 Returns 1 if the requested type matches, 0 if it doesn't and
 
 487 C<confess>es if an unknown C<$type> parameter is encountered.
 
 495 Shorthand for C<is_type('part')> etc.
 
 497 =item C<get_sellprice_info %params>
 
 499 Retrieves the C<sellprice> and C<price_factor_id> for a part under
 
 500 different conditions and returns a hash reference with those two keys.
 
 502 If C<%params> contains a key C<project_id> then a project price list
 
 503 will be consulted if one exists for that project. In this case the
 
 504 parameter C<country_id> is evaluated as well: if a price list entry
 
 505 has been created for this country then it will be used. Otherwise an
 
 506 entry without a country set will be used.
 
 508 If none of the above conditions is met then the information from
 
 511 =item C<get_ordered_qty %params>
 
 513 Retrieves the quantity that has been ordered from a vendor but that
 
 514 has not been delivered yet. Only open purchase orders are considered.
 
 516 =item C<get_taxkey %params>
 
 518 Retrieves and returns a taxkey object valid for the given date
 
 519 C<$params{date}> and tax zone C<$params{taxzone}>
 
 520 (C<$params{taxzone_id}> is also recognized). The date defaults to the
 
 521 current date if undefined.
 
 523 This function looks up the income (for trueish values of
 
 524 C<$params{is_sales}>) or expense (for falsish values of
 
 525 C<$params{is_sales}>) account for the current part. It uses the part's
 
 526 associated buchungsgruppe and uses the fields belonging to the tax
 
 527 zone given by C<$params{taxzone}>.
 
 529 The information retrieved by the function is cached.
 
 531 =item C<get_chart %params>
 
 533 Retrieves and returns a chart object valid for the given type
 
 534 C<$params{type}> and tax zone C<$params{taxzone}>
 
 535 (C<$params{taxzone_id}> is also recognized). The type must be one of
 
 536 the three key words C<income>, C<expense> and C<inventory>.
 
 538 This function uses the part's associated buchungsgruppe and uses the
 
 539 fields belonging to the tax zone given by C<$params{taxzone}>.
 
 541 The information retrieved by the function is cached.
 
 543 =item C<used_in_record>
 
 545 Checks if this article has been used in orders, invoices or delivery orders.
 
 549 Checks if this article is used in orders, invoices, delivery orders or
 
 552 =item C<buchungsgruppe BUCHUNGSGRUPPE>
 
 554 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
 
 555 Please note, that this is a write only accessor, the original Buchungsgruppe can
 
 556 not be retrieved from an article once set.
 
 558 =item C<items_lastcost_sum>
 
 560 Non-recursive lastcost sum of all the items in an assembly or assortment.
 
 562 =item C<get_stock %params>
 
 564 Fetches stock qty in the default unit for a part.
 
 566 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
 
 567 the stock qty for that bin is returned. If only a warehouse_id is passed, the
 
 568 stock qty for all bins in that warehouse is returned.  If a shippingdate is
 
 569 passed the stock qty for that date is returned.
 
 572  my $qty = $part->get_stock(bin_id => 52);
 
 574  $part->get_stock(shippingdate => DateTime->today->add(days => -5));
 
 580 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
 
 581 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>