6 use List::MoreUtils qw(any uniq);
 
   7 use Rose::DB::Object::Helpers qw(as_tree);
 
   9 use SL::Locale::String qw(t8);
 
  11 use SL::DB::MetaSetup::Part;
 
  12 use SL::DB::Manager::Part;
 
  14 use SL::DB::Helper::AttrHTML;
 
  15 use SL::DB::Helper::AttrSorted;
 
  16 use SL::DB::Helper::TransNumberGenerator;
 
  17 use SL::DB::Helper::CustomVariables (
 
  21 use SL::DB::Helper::DisplayableNamePreferences (
 
  22   title   => t8('Article'),
 
  23   options => [ {name => 'partnumber',  title => t8('Part Number')     },
 
  24                {name => 'description', title => t8('Description')    },
 
  25                {name => 'notes',       title => t8('Notes')},
 
  26                {name => 'ean',         title => t8('EAN')            }, ],
 
  29 use List::Util qw(sum);
 
  31 __PACKAGE__->meta->add_relationships(
 
  33     type         => 'one to many',
 
  34     class        => 'SL::DB::Assembly',
 
  35     manager_args => { sort_by => 'position' },
 
  36     column_map   => { id => 'id' },
 
  39     type         => 'one to many',
 
  40     class        => 'SL::DB::Price',
 
  41     column_map   => { id => 'parts_id' },
 
  42     manager_args => { with_objects => [ 'pricegroup' ] }
 
  45     type         => 'one to many',
 
  46     class        => 'SL::DB::MakeModel',
 
  47     manager_args => { sort_by => 'sortorder' },
 
  48     column_map   => { id => 'parts_id' },
 
  51     type         => 'one to many',
 
  52     class        => 'SL::DB::PartCustomerPrice',
 
  53     column_map   => { id => 'parts_id' },
 
  56     type         => 'one to many',
 
  57     class        => 'SL::DB::Translation',
 
  58     column_map   => { id => 'parts_id' },
 
  61     type         => 'one to many',
 
  62     class        => 'SL::DB::AssortmentItem',
 
  63     column_map   => { id => 'assortment_id' },
 
  64     manager_args => { sort_by => 'position' },
 
  67     type            => 'one to many',
 
  68     class           => 'SL::DB::History',
 
  69     column_map      => { id => 'trans_id' },
 
  70     query_args      => [ what_done => 'part' ],
 
  71     manager_args    => { sort_by => 'itime' },
 
  74     type         => 'one to many',
 
  75     class        => 'SL::DB::ShopPart',
 
  76     column_map   => { id => 'part_id' },
 
  77     manager_args => { with_objects => [ 'shop' ] },
 
  79   last_price_update => {
 
  81     class        => 'SL::DB::PartsPriceHistory',
 
  82     column_map   => { id => 'part_id' },
 
  83     manager_args => { sort_by => 'valid_from DESC', limit => 1 },
 
  87 __PACKAGE__->meta->initialize;
 
  89 __PACKAGE__->attr_html('notes');
 
  90 __PACKAGE__->attr_sorted({ unsorted => 'makemodels',     position => 'sortorder' });
 
  91 __PACKAGE__->attr_sorted({ unsorted => 'customerprices', position => 'sortorder' });
 
  93 __PACKAGE__->before_save('_before_save_set_partnumber');
 
  95 sub _before_save_set_partnumber {
 
  98   $self->create_trans_number if !$self->partnumber;
 
 105   if ( $self->part_type eq 'assembly' ) {
 
 106     return $self->assemblies;
 
 107   } elsif ( $self->part_type eq 'assortment' ) {
 
 108     return $self->assortment_items;
 
 117   # for detecting if the items of an (orphaned) assembly or assortment have
 
 118   # changed when saving
 
 120   return join(' ', sort map { $_->part->id } @{$self->items});
 
 127   push @errors, $::locale->text('The partnumber is missing.')     if $self->id and !$self->partnumber;
 
 128   push @errors, $::locale->text('The unit is missing.')           unless $self->unit;
 
 129   push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
 
 131   unless ( $self->id ) {
 
 132     push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
 
 135   if ($self->is_assortment && $self->orphaned && scalar @{$self->assortment_items} == 0) {
 
 136     # when assortment isn't orphaned form doesn't contain any items
 
 137     push @errors, $::locale->text('The assortment doesn\'t have any items.');
 
 140   if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
 
 141     push @errors, $::locale->text('The assembly doesn\'t have any items.');
 
 149   my $type  = lc(shift || '');
 
 150   die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
 
 152   return $self->type eq $type ? 1 : 0;
 
 155 sub is_part       { $_[0]->part_type eq 'part'       }
 
 156 sub is_assembly   { $_[0]->part_type eq 'assembly'   }
 
 157 sub is_service    { $_[0]->part_type eq 'service'    }
 
 158 sub is_assortment { $_[0]->part_type eq 'assortment' }
 
 161   return $_[0]->part_type;
 
 162   # my ($self, $type) = @_;
 
 164   #   die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
 
 165   #   $self->assembly(          $type eq 'assembly' ? 1 : 0);
 
 166   #   $self->inventory_accno_id($type ne 'service'  ? 1 : undef);
 
 169   # return 'assembly' if $self->assembly;
 
 170   # return 'part'     if $self->inventory_accno_id;
 
 175   my ($class, %params) = @_;
 
 176   $class->new(%params, part_type => 'part');
 
 180   my ($class, %params) = @_;
 
 181   $class->new(%params, part_type => 'assembly');
 
 185   my ($class, %params) = @_;
 
 186   $class->new(%params, part_type => 'service');
 
 190   my ($class, %params) = @_;
 
 191   $class->new(%params, part_type => 'assortment');
 
 194 sub last_modification {
 
 196   return $self->mtime // $self->itime;
 
 201   die 'not an accessor' if @_ > 1;
 
 203   return 1 unless $self->id;
 
 208     SL::DB::DeliveryOrderItem
 
 211   for my $class (@relations) {
 
 212     eval "require $class";
 
 213     return 1 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
 
 219   die 'not an accessor' if @_ > 1;
 
 221   return 1 unless $self->id;
 
 226     SL::DB::DeliveryOrderItem
 
 228     SL::DB::AssortmentItem
 
 231   for my $class (@relations) {
 
 232     eval "require $class";
 
 233     return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
 
 238 sub get_sellprice_info {
 
 242   confess "Missing part id" unless $self->id;
 
 244   my $object = $self->load;
 
 246   return { sellprice       => $object->sellprice,
 
 247            price_factor_id => $object->price_factor_id };
 
 250 sub get_ordered_qty {
 
 252   my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
 
 254   return $result{ $self->id };
 
 257 sub available_units {
 
 258   shift->unit_obj->convertible_units;
 
 261 # autogenerated accessor is slightly off...
 
 263   shift->buchungsgruppen(@_);
 
 267   my ($self, %params) = @_;
 
 269   my $date     = $params{date} || DateTime->today_local;
 
 270   my $is_sales = !!$params{is_sales};
 
 271   my $taxzone  = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
 
 272   my $tk_info  = $::request->cache('get_taxkey');
 
 274   $tk_info->{$self->id}                                      //= {};
 
 275   $tk_info->{$self->id}->{$taxzone}                          //= { };
 
 276   my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
 
 278   if (!exists $cache->{$date}) {
 
 280       $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
 
 281       ->get_active_taxkey($date);
 
 284   return $cache->{$date};
 
 288   my ($self, %params) = @_;
 
 290   my $type    = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
 
 291   my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
 
 293   my $charts     = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
 
 294   my $all_charts = $::request->cache('get_chart_id/by_id');
 
 296   $charts->{$taxzone} ||= { };
 
 298   if (!exists $charts->{$taxzone}->{$type}) {
 
 299     require SL::DB::Buchungsgruppe;
 
 300     my $bugru    = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
 
 301     my $chart_id = ($type eq 'inventory') ? ($self->is_part ? $bugru->inventory_accno_id : undef)
 
 302                  :                          $bugru->call_sub("${type}_accno_id", $taxzone);
 
 305       my $chart                    = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
 
 306       $all_charts->{$chart_id}     = $chart;
 
 307       $charts->{$taxzone}->{$type} = $chart;
 
 311   return $charts->{$taxzone}->{$type};
 
 315   my ($self, %params) = @_;
 
 317   return undef unless $self->id;
 
 319   my $query = 'SELECT SUM(qty) FROM inventory WHERE parts_id = ?';
 
 320   my @values = ($self->id);
 
 322   if ( $params{bin_id} ) {
 
 323     $query .= ' AND bin_id = ?';
 
 324     push(@values, $params{bin_id});
 
 327   if ( $params{warehouse_id} ) {
 
 328     $query .= ' AND warehouse_id = ?';
 
 329     push(@values, $params{warehouse_id});
 
 332   if ( $params{shippingdate} ) {
 
 333     die unless ref($params{shippingdate}) eq 'DateTime';
 
 334     $query .= ' AND shippingdate <= ?';
 
 335     push(@values, $params{shippingdate});
 
 338   my ($stock) = selectrow_query($::form, $self->db->dbh, $query, @values);
 
 340   return $stock || 0; # never return undef
 
 344 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
 
 345 sub get_simple_stock {
 
 346   my ($self, %params) = @_;
 
 348   return [] unless $self->id;
 
 351     SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
 
 352     GROUP BY warehouse_id, bin_id
 
 354   my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
 
 355   [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
 
 357 # helper class to have bin/warehouse accessors in stock result
 
 358 { package SL::DB::Part::SimpleStock;
 
 359   sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
 
 360   sub bin       { require SL::DB::Bin;       SL::DB::Manager::Bin      ->find_by_or_create(id => $_[0]->{bin_id}) }
 
 363 sub get_simple_stock_sql {
 
 364   my ($self, %params) = @_;
 
 366   return [] unless $self->id;
 
 369      SELECT w.description                         AS warehouse_description,
 
 370             b.description                         AS bin_description,
 
 372             SUM(i.qty * p.lastcost)               AS stock_value,
 
 374             LEAD(w.description)           OVER pt AS wh_lead,            -- to detect warehouse changes for subtotals in template
 
 375             SUM( SUM(i.qty) )             OVER pt AS run_qty,            -- running total of total qty
 
 376             SUM( SUM(i.qty) )             OVER wh AS wh_run_qty,         -- running total of warehouse qty
 
 377             SUM( SUM(i.qty * p.lastcost)) OVER pt AS run_stock_value,    -- running total of total stock_value
 
 378             SUM( SUM(i.qty * p.lastcost)) OVER wh AS wh_run_stock_value  -- running total of warehouse stock_value
 
 380             LEFT JOIN parts p     ON (p.id           = i.parts_id)
 
 381             LEFT JOIN warehouse w ON (i.warehouse_id = w.id)
 
 382             LEFT JOIN bin b       ON (i.bin_id       = b.id)
 
 384    GROUP BY w.description, w.sortkey, b.description, p.unit, i.parts_id
 
 386      WINDOW pt AS (PARTITION BY i.parts_id    ORDER BY w.sortkey, b.description, p.unit),
 
 387             wh AS (PARTITION by w.description ORDER BY w.sortkey, b.description, p.unit)
 
 388    ORDER BY w.sortkey, b.description, p.unit
 
 391   my $stock_info = selectall_hashref_query($::form, $self->db->dbh, $query, $self->id);
 
 395 sub get_mini_journal {
 
 398   # inventory ids of the most recent 10 inventory trans_ids
 
 400   # duplicate code copied from SL::Controller::Inventory mini_journal, except
 
 401   # for the added filter on parts_id
 
 403   my $parts_id = $self->id;
 
 405 with last_inventories as (
 
 410     where parts_id = $parts_id
 
 417      from last_inventories
 
 424  limit 20  -- so the planner knows how many ids to expect, the cte is an optimisation fence
 
 427   my $objs  = SL::DB::Manager::Inventory->get_all(
 
 428     query        => [ id => [ \"$query" ] ],
 
 429     with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
 
 430     sort_by      => 'itime DESC',
 
 432   # remember order of trans_ids from query, for ordering hash later
 
 433   my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
 
 435   # at most 2 of them belong to a transaction and the qty determines in or out.
 
 438     $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
 
 439     $transactions{ $_->trans_id }{base} = $_;
 
 442   # because the inventory transactions were built in a hash, we need to sort the
 
 443   # hash by using the original sort order of the trans_ids
 
 444   my @sorted = map { $transactions{$_} } @sorted_trans_ids;
 
 449 sub clone_and_reset_deep {
 
 452   my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
 
 453   $clone->makemodels(   map { $_->clone_and_reset } @{$self->makemodels}   ) if @{$self->makemodels};
 
 454   $clone->translations( map { $_->clone_and_reset } @{$self->translations} ) if @{$self->translations};
 
 456   if ( $self->is_assortment ) {
 
 457     # use clone rather than reset_and_clone because the unique constraint would also remove parts_id
 
 458     $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
 
 459     $_->assortment_id(undef) foreach @{ $clone->assortment_items }
 
 462   if ( $self->is_assembly ) {
 
 463     $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
 
 466   if ( $self->prices ) {
 
 467     $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
 
 468     if ( $clone->prices ) {
 
 469       foreach my $price ( @{$clone->prices} ) {
 
 471         $price->parts_id(undef);
 
 480   my ($self, $comparison_part) = @_;
 
 482   die "item_diffs needs a part object" unless ref($comparison_part) eq 'SL::DB::Part';
 
 483   die "part and comparison_part need to be of the same part_type" unless
 
 484         ( $self->part_type eq 'assembly' or $self->part_type eq 'assortment' )
 
 485     and ( $comparison_part->part_type eq 'assembly' or $comparison_part->part_type eq 'assortment' )
 
 486     and $self->part_type eq $comparison_part->part_type;
 
 488   # return [], [] if $self->items_checksum eq $comparison_part->items_checksum;
 
 489   my @self_part_ids       = map { $_->parts_id } $self->items;
 
 490   my @comparison_part_ids = map { $_->parts_id } $comparison_part->items;
 
 492   my %orig       = map{ $_ => 1 } @self_part_ids;
 
 493   my %comparison = map{ $_ => 1 } @comparison_part_ids;
 
 494   my (@additions, @removals);
 
 495   @additions = grep { !exists( $orig{$_}       ) } @comparison_part_ids if @comparison_part_ids;
 
 496   @removals  = grep { !exists( $comparison{$_} ) } @self_part_ids       if @self_part_ids;
 
 498   return \@additions, \@removals;
 
 501 sub items_sellprice_sum {
 
 502   my ($self, %params) = @_;
 
 504   return unless $self->is_assortment or $self->is_assembly;
 
 505   return unless $self->items;
 
 507   if ($self->is_assembly) {
 
 508     return sum map { $_->linetotal_sellprice          } @{$self->items};
 
 510     return sum map { $_->linetotal_sellprice(%params) } grep { $_->charge } @{$self->items};
 
 514 sub items_lastcost_sum {
 
 517   return unless $self->is_assortment or $self->is_assembly;
 
 518   return unless $self->items;
 
 519   sum map { $_->linetotal_lastcost } @{$self->items};
 
 532 SL::DB::Part: Model for the 'parts' table
 
 536 This is a standard Rose::DB::Object based model and can be used as one.
 
 540 Although the base class is called C<Part> we usually talk about C<Articles> if
 
 541 we mean instances of this class. This is because articles come in three
 
 546 =item Part     - a single part
 
 548 =item Service  - a part without onhand, and without inventory accounting
 
 550 =item Assembly - a collection of both parts and services
 
 552 =item Assortment - a collection of items (parts or assemblies)
 
 556 These types are sadly represented by data inside the class and cannot be
 
 557 migrated into a flag. To work around this, each C<Part> object knows what type
 
 558 it currently is. Since the type is data driven, there ist no explicit setting
 
 559 method for it, but you can construct them explicitly with C<new_part>,
 
 560 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
 
 561 case, but it will use the default Buchungsgruppe if you don't.
 
 563 Matching these there are assorted helper methods dealing with types,
 
 564 e.g.  L</new_part>, L</new_service>, L</new_assembly>, L</type>,
 
 565 L</is_type> and others.
 
 571 =item C<new_part %PARAMS>
 
 573 =item C<new_service %PARAMS>
 
 575 =item C<new_assembly %PARAMS>
 
 577 Will set the appropriate data fields so that the resulting instance will be of
 
 578 the requested type. Since accounting targets are part of the distinction,
 
 579 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
 
 580 will load a default one and set the accounting targets from it.
 
 584 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
 
 586 =item C<is_type $TYPE>
 
 588 Tests if the current object is a part, a service or an
 
 589 assembly. C<$type> must be one of the words 'part', 'service' or
 
 590 'assembly' (their plurals are ok, too).
 
 592 Returns 1 if the requested type matches, 0 if it doesn't and
 
 593 C<confess>es if an unknown C<$type> parameter is encountered.
 
 601 Shorthand for C<is_type('part')> etc.
 
 603 =item C<get_sellprice_info %params>
 
 605 Retrieves the C<sellprice> and C<price_factor_id> for a part under
 
 606 different conditions and returns a hash reference with those two keys.
 
 608 If C<%params> contains a key C<project_id> then a project price list
 
 609 will be consulted if one exists for that project. In this case the
 
 610 parameter C<country_id> is evaluated as well: if a price list entry
 
 611 has been created for this country then it will be used. Otherwise an
 
 612 entry without a country set will be used.
 
 614 If none of the above conditions is met then the information from
 
 617 =item C<get_ordered_qty %params>
 
 619 Retrieves the quantity that has been ordered from a vendor but that
 
 620 has not been delivered yet. Only open purchase orders are considered.
 
 622 =item C<get_taxkey %params>
 
 624 Retrieves and returns a taxkey object valid for the given date
 
 625 C<$params{date}> and tax zone C<$params{taxzone}>
 
 626 (C<$params{taxzone_id}> is also recognized). The date defaults to the
 
 627 current date if undefined.
 
 629 This function looks up the income (for trueish values of
 
 630 C<$params{is_sales}>) or expense (for falsish values of
 
 631 C<$params{is_sales}>) account for the current part. It uses the part's
 
 632 associated buchungsgruppe and uses the fields belonging to the tax
 
 633 zone given by C<$params{taxzone}>.
 
 635 The information retrieved by the function is cached.
 
 637 =item C<get_chart %params>
 
 639 Retrieves and returns a chart object valid for the given type
 
 640 C<$params{type}> and tax zone C<$params{taxzone}>
 
 641 (C<$params{taxzone_id}> is also recognized). The type must be one of
 
 642 the three key words C<income>, C<expense> and C<inventory>.
 
 644 This function uses the part's associated buchungsgruppe and uses the
 
 645 fields belonging to the tax zone given by C<$params{taxzone}>.
 
 647 The information retrieved by the function is cached.
 
 649 =item C<used_in_record>
 
 651 Checks if this article has been used in orders, invoices or delivery orders.
 
 655 Checks if this article is used in orders, invoices, delivery orders or
 
 658 =item C<buchungsgruppe BUCHUNGSGRUPPE>
 
 660 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
 
 661 Please note, that this is a write only accessor, the original Buchungsgruppe can
 
 662 not be retrieved from an article once set.
 
 664 =item C<get_simple_stock_sql>
 
 666 Fetches the qty and the stock value for the current part for each bin and
 
 667 warehouse where the part is in stock (or rather different from 0, might be
 
 670 Runs some additional window functions to add the running totals (total running
 
 671 total and total per warehouse) for qty and stock value to each line.
 
 673 Using the LEAD(w.description) the template can check if the warehouse
 
 674 description is about to change, i.e. the next line will contain numbers from a
 
 675 different warehouse, so that a subtotal line can be added.
 
 677 The last row will contain the running qty total (run_qty) and the running total
 
 678 stock value (run_stock_value) over all warehouses/bins and can be used to add a
 
 679 line for the grand totals.
 
 681 =item C<items_lastcost_sum>
 
 683 Non-recursive lastcost sum of all the items in an assembly or assortment.
 
 685 =item C<get_stock %params>
 
 687 Fetches stock qty in the default unit for a part.
 
 689 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
 
 690 the stock qty for that bin is returned. If only a warehouse_id is passed, the
 
 691 stock qty for all bins in that warehouse is returned.  If a shippingdate is
 
 692 passed the stock qty for that date is returned.
 
 695  my $qty = $part->get_stock(bin_id => 52);
 
 697  $part->get_stock(shippingdate => DateTime->today->add(days => -5));
 
 703 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
 
 704 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>