1 package SL::Controller::Part;
 
   4 use parent qw(SL::Controller::Base);
 
   8 use SL::DB::PartsGroup;
 
   9 use SL::Controller::Helper::GetModels;
 
  10 use SL::Locale::String qw(t8);
 
  12 use List::Util qw(sum);
 
  13 use SL::Helper::Flash;
 
  17 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  19 use SL::MoreCommon qw(save_form);
 
  22 use Rose::Object::MakeMethods::Generic (
 
  23   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  26                                   assortment assortment_items assembly assembly_items
 
  27                                   all_pricegroups all_translations all_partsgroups all_units
 
  28                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  29                                   parts_classification_filter
 
  30                                   all_languages all_units all_price_factors) ],
 
  31   'scalar'                => [ qw(warehouse bin) ],
 
  35 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  36                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  38 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  40 # actions for editing parts
 
  43   my ($self, %params) = @_;
 
  45   $::form->{callback} = $self->url_for(action => 'add_part') unless $::form->{callback};
 
  46   $self->part( SL::DB::Part->new_part );
 
  50 sub action_add_service {
 
  51   my ($self, %params) = @_;
 
  53   $::form->{callback} = $self->url_for(action => 'add_service') unless $::form->{callback};
 
  54   $self->part( SL::DB::Part->new_service );
 
  58 sub action_add_assembly {
 
  59   my ($self, %params) = @_;
 
  61   $::form->{callback} = $self->url_for(action => 'add_assembly') unless $::form->{callback};
 
  62   $self->part( SL::DB::Part->new_assembly );
 
  66 sub action_add_assortment {
 
  67   my ($self, %params) = @_;
 
  69   $::form->{callback} = $self->url_for(action => 'add_assortment') unless $::form->{callback};
 
  70   $self->part( SL::DB::Part->new_assortment );
 
  74 sub action_add_from_record {
 
  77   check_has_valid_part_type($::form->{part}{part_type});
 
  79   die 'parts_classification_type must be "sales" or "purchases"'
 
  80     unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
 
  89   check_has_valid_part_type($::form->{part_type});
 
  91   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  92   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  93   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  94   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  98   my ($self, %params) = @_;
 
 100   # checks that depend only on submitted $::form
 
 101   $self->check_form or return $self->js->render;
 
 103   my $is_new = !$self->part->id; # $ part gets loaded here
 
 105   # check that the part hasn't been modified
 
 107     $self->check_part_not_modified or
 
 108       return $self->js->error(t8('The document has been changed by another user. Please reopen it in another window and copy the changes to the new window'))->render;
 
 112        && $::form->{part}{partnumber}
 
 113        && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
 
 115     return $self->js->error(t8('The partnumber is already being used'))->render;
 
 120   my @errors = $self->part->validate;
 
 121   return $self->js->error(@errors)->render if @errors;
 
 123   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 124   $self->part->db->with_transaction(sub {
 
 126     if ( $params{save_as_new} ) {
 
 127       $self->part( $self->part->clone_and_reset_deep );
 
 128       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 131     $self->part->save(cascade => 1);
 
 133     SL::DB::History->new(
 
 134       trans_id    => $self->part->id,
 
 135       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 136       employee_id => SL::DB::Manager::Employee->current->id,
 
 141     CVar->save_custom_variables(
 
 142         dbh          => $self->part->db->dbh,
 
 144         trans_id     => $self->part->id,
 
 145         variables    => $::form, # $::form->{cvar} would be nicer
 
 150   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 153   flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
 
 155   if ( $::form->{callback} ) {
 
 156     $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
 
 159     # default behaviour after save: reload item, this also resets last_modification!
 
 160     $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 164 sub action_save_as_new {
 
 166   $self->action_save(save_as_new=>1);
 
 172   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 174   my $partnumber = $self->part->partnumber; # remember for history log
 
 179       # delete part, together with relationships that don't already
 
 180       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 181       $self->part->delete(cascade => 1);
 
 183       SL::DB::History->new(
 
 184         trans_id    => $self->part->id,
 
 185         snumbers    => 'partnumber_' . $partnumber,
 
 186         employee_id => SL::DB::Manager::Employee->current->id,
 
 188         addition    => 'DELETED',
 
 191   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 193   flash_later('info', t8('The item has been deleted.'));
 
 194   if ( $::form->{callback} ) {
 
 195     $self->redirect_to($::form->unescape($::form->{callback}));
 
 197     my @redirect_params = (
 
 198         controller => 'controller.pl',
 
 199         action     => 'LoginScreen/user_login'
 
 201     $self->redirect_to(@redirect_params);
 
 205 sub action_use_as_new {
 
 206   my ($self, %params) = @_;
 
 208   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 209   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 211   $self->part($oldpart->clone_and_reset_deep);
 
 213   $self->part->partnumber(undef);
 
 219   my ($self, %params) = @_;
 
 225   my ($self, %params) = @_;
 
 227   $self->_set_javascript;
 
 228   $self->_setup_form_action_bar;
 
 230   my (%assortment_vars, %assembly_vars);
 
 231   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 232   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 234   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 236   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 237     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 239   my %title_hash = ( part       => t8('Edit Part'),
 
 240                      assembly   => t8('Edit Assembly'),
 
 241                      service    => t8('Edit Service'),
 
 242                      assortment => t8('Edit Assortment'),
 
 245   $self->part->prices([])       unless $self->part->prices;
 
 246   $self->part->translations([]) unless $self->part->translations;
 
 250     title             => $title_hash{$self->part->part_type},
 
 253     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 254     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 255     oldpartnumber     => $::form->{oldpartnumber},
 
 256     old_id            => $::form->{old_id},
 
 264   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 265   $_[0]->render('part/history', { layout => 0 },
 
 266                                   history_entries => $history_entries);
 
 269 sub action_update_item_totals {
 
 272   my $part_type = $::form->{part_type};
 
 273   die unless $part_type =~ /^(assortment|assembly)$/;
 
 275   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 276   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 278   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 281     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 282     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 283     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 284     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 285     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 286     ->no_flash_clear->render();
 
 289 sub action_add_multi_assortment_items {
 
 292   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 293   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 295   $self->js->run('kivi.Part.close_picker_dialogs')
 
 296            ->append('#assortment_rows', $html)
 
 297            ->run('kivi.Part.renumber_positions')
 
 298            ->run('kivi.Part.assortment_recalc')
 
 302 sub action_add_multi_assembly_items {
 
 305   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 307   foreach my $item (@{$item_objects}) {
 
 308     my $errstr = validate_assembly($item->part,$self->part);
 
 309     $self->js->flash('error',$errstr) if     $errstr;
 
 310     push (@checked_objects,$item)     unless $errstr;
 
 313   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 315   $self->js->run('kivi.Part.close_picker_dialogs')
 
 316            ->append('#assembly_rows', $html)
 
 317            ->run('kivi.Part.renumber_positions')
 
 318            ->run('kivi.Part.assembly_recalc')
 
 322 sub action_add_assortment_item {
 
 323   my ($self, %params) = @_;
 
 325   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 327   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 329   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 330   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 331     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 334   my $number_of_items = scalar @{$self->assortment_items};
 
 335   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 336   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 338   push(@{$self->assortment_items}, @{$item_objects});
 
 339   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 340   $part->assortment_items(@{$self->assortment_items});
 
 341   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 342   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 343   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 346     ->append('#assortment_rows'        , $html)  # append in tbody
 
 347     ->val('.add_assortment_item_input' , '')
 
 348     ->run('kivi.Part.focus_last_assortment_input')
 
 349     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 350     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 351     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 352     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 353     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 357 sub action_add_assembly_item {
 
 360   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 362   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 364   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 366   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 367   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 368     $duplicate_warning++;
 
 371   my $number_of_items = scalar @{$self->assembly_items};
 
 372   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 374     foreach my $item (@{$item_objects}) {
 
 375       my $errstr = validate_assembly($item->part,$self->part);
 
 376       return $self->js->flash('error',$errstr)->render if $errstr;
 
 381   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 383   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 385   push(@{$self->assembly_items}, @{$item_objects});
 
 386   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 387   $part->assemblies(@{$self->assembly_items});
 
 388   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 389   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 390   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 393     ->append('#assembly_rows', $html)  # append in tbody
 
 394     ->val('.add_assembly_item_input' , '')
 
 395     ->run('kivi.Part.focus_last_assembly_input')
 
 396     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 397     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 398     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 399     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 400     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 404 sub action_show_multi_items_dialog {
 
 405   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 406     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 410 sub action_multi_items_update_result {
 
 413   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 415   my $count = $_[0]->multi_items_models->count;
 
 418     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 419     $_[0]->render($text, { layout => 0 });
 
 420   } elsif ($count > $max_count) {
 
 421     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 422     $_[0]->render($text, { layout => 0 });
 
 424     my $multi_items = $_[0]->multi_items_models->get;
 
 425     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 426                   multi_items => $multi_items);
 
 430 sub action_add_makemodel_row {
 
 433   my $vendor_id = $::form->{add_makemodel};
 
 435   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 436     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 438   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 439     $self->js->flash('info', t8("This vendor has already been added."));
 
 442   my $position = scalar @{$self->makemodels} + 1;
 
 444   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 448                                   sortorder    => $position,
 
 449                                  ) or die "Can't create MakeModel object";
 
 451   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 453                                      listrow   => $position % 2 ? 0 : 1,
 
 456   # after selection focus on the model field in the row that was just added
 
 458     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 459     ->val('.add_makemodel_input', '')
 
 460     ->run('kivi.Part.focus_last_makemodel_input')
 
 464 sub action_reorder_items {
 
 467   my $part_type = $::form->{part_type};
 
 470     partnumber  => sub { $_[0]->part->partnumber },
 
 471     description => sub { $_[0]->part->description },
 
 472     qty         => sub { $_[0]->qty },
 
 473     sellprice   => sub { $_[0]->part->sellprice },
 
 474     lastcost    => sub { $_[0]->part->lastcost },
 
 475     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 478   my $method = $sort_keys{$::form->{order_by}};
 
 481   if ($part_type eq 'assortment') {
 
 482     @items = @{ $self->assortment_items };
 
 484     @items = @{ $self->assembly_items };
 
 487   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 488   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 489     if ($::form->{sort_dir}) {
 
 490       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 492       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 495     if ($::form->{sort_dir}) {
 
 496       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 498       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 502   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 505 sub action_warehouse_changed {
 
 508   if ($::form->{warehouse_id} ) {
 
 509     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 510     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 512     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 513       $self->bin($self->warehouse->bins->[0]);
 
 515         ->html('#bin', $self->build_bin_select)
 
 516         ->focus('#part_bin_id');
 
 517       return $self->js->render;
 
 521   # no warehouse was selected, empty the bin field and reset the id
 
 523        ->val('#part_bin_id', undef)
 
 526   return $self->js->render;
 
 529 sub action_ajax_autocomplete {
 
 530   my ($self, %params) = @_;
 
 532   # if someone types something, and hits enter, assume he entered the full name.
 
 533   # if something matches, treat that as sole match
 
 534   # since we need a second get models instance with different filters for that,
 
 535   # we only modify the original filter temporarily in place
 
 536   if ($::form->{prefer_exact}) {
 
 537     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 539     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 542       paginated    => { per_page => 2 },
 
 543       with_objects => [ qw(unit_obj classification) ],
 
 546     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 547       $self->parts($exact_matches);
 
 553      value       => $_->displayable_name,
 
 554      label       => $_->displayable_name,
 
 556      partnumber  => $_->partnumber,
 
 557      description => $_->description,
 
 558      part_type   => $_->part_type,
 
 560      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 562   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 564   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 567 sub action_test_page {
 
 568   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 571 sub action_part_picker_search {
 
 572   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 575 sub action_part_picker_result {
 
 576   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 582   if ($::request->type eq 'json') {
 
 587       $part_hash          = $self->part->as_tree;
 
 588       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 591     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 596 sub validate_add_items {
 
 597   scalar @{$::form->{add_items}};
 
 600 sub prepare_assortment_render_vars {
 
 603   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 604                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 605                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 607   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 612 sub prepare_assembly_render_vars {
 
 615   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 616                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 617                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 619   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 627   check_has_valid_part_type($self->part->part_type);
 
 629   $self->_set_javascript;
 
 630   $self->_setup_form_action_bar;
 
 632   my %title_hash = ( part       => t8('Add Part'),
 
 633                      assembly   => t8('Add Assembly'),
 
 634                      service    => t8('Add Service'),
 
 635                      assortment => t8('Add Assortment'),
 
 640     title => $title_hash{$self->part->part_type},
 
 645 sub _set_javascript {
 
 647   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
 
 648   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 651 sub recalc_item_totals {
 
 652   my ($self, %params) = @_;
 
 654   if ( $params{part_type} eq 'assortment' ) {
 
 655     return 0 unless scalar @{$self->assortment_items};
 
 656   } elsif ( $params{part_type} eq 'assembly' ) {
 
 657     return 0 unless scalar @{$self->assembly_items};
 
 659     carp "can only calculate sum for assortments and assemblies";
 
 662   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 663   if ( $part->is_assortment ) {
 
 664     $part->assortment_items( @{$self->assortment_items} );
 
 665     if ( $params{price_type} eq 'lastcost' ) {
 
 666       return $part->items_lastcost_sum;
 
 668       if ( $params{pricegroup_id} ) {
 
 669         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 671         return $part->items_sellprice_sum;
 
 674   } elsif ( $part->is_assembly ) {
 
 675     $part->assemblies( @{$self->assembly_items} );
 
 676     if ( $params{price_type} eq 'lastcost' ) {
 
 677       return $part->items_lastcost_sum;
 
 679       return $part->items_sellprice_sum;
 
 684 sub check_part_not_modified {
 
 687   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 694   my $is_new = !$self->part->id;
 
 696   my $params = delete($::form->{part}) || { };
 
 698   delete $params->{id};
 
 699   $self->part->assign_attributes(%{ $params});
 
 700   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 702   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 703   # will be the case for used assortments when saving, or when a used assortment
 
 705   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 706     $self->part->assortment_items([]);
 
 707     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 710   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 711     $self->part->assemblies([]); # completely rewrite assortments each time
 
 712     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 715   $self->part->translations([]);
 
 716   $self->parse_form_translations;
 
 718   $self->part->prices([]);
 
 719   $self->parse_form_prices;
 
 721   $self->parse_form_makemodels;
 
 724 sub parse_form_prices {
 
 726   # only save prices > 0
 
 727   my $prices = delete($::form->{prices}) || [];
 
 728   foreach my $price ( @{$prices} ) {
 
 729     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 730     next unless $sellprice > 0; # skip negative prices as well
 
 731     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 732                                pricegroup_id => $price->{pricegroup_id},
 
 735     $self->part->add_prices($p);
 
 739 sub parse_form_translations {
 
 741   # don't add empty translations
 
 742   my $translations = delete($::form->{translations}) || [];
 
 743   foreach my $translation ( @{$translations} ) {
 
 744     next unless $translation->{translation};
 
 745     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 746     $self->part->add_translations( $translation );
 
 750 sub parse_form_makemodels {
 
 754   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 755     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 758   $self->part->makemodels([]);
 
 761   my $makemodels = delete($::form->{makemodels}) || [];
 
 762   foreach my $makemodel ( @{$makemodels} ) {
 
 763     next unless $makemodel->{make};
 
 765     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 767     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 768                                      id         => $makemodel->{id},
 
 769                                      make       => $makemodel->{make},
 
 770                                      model      => $makemodel->{model} || '',
 
 771                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 772                                      sortorder  => $position,
 
 774     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 775       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 776       # don't change lastupdate
 
 777     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 778       # new makemodel, no lastcost entered, leave lastupdate empty
 
 779     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 780       # lastcost hasn't changed, use original lastupdate
 
 781       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 783       $mm->lastupdate(DateTime->now);
 
 785     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 786     $self->part->add_makemodels($mm);
 
 790 sub build_bin_select {
 
 791   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 792     title_key => 'description',
 
 793     default   => $_[0]->bin->id,
 
 797 # get_set_inits for partpicker
 
 800   if ($::form->{no_paginate}) {
 
 801     $_[0]->models->disable_plugin('paginated');
 
 807 # get_set_inits for part controller
 
 811   # used by edit, save, delete and add
 
 813   if ( $::form->{part}{id} ) {
 
 814     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
 
 816     die "part_type missing" unless $::form->{part}{part_type};
 
 817     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 823   return $self->part->orphaned;
 
 829   SL::Controller::Helper::GetModels->new(
 
 836       partnumber  => t8('Partnumber'),
 
 837       description  => t8('Description'),
 
 839     with_objects => [ qw(unit_obj classification) ],
 
 848 sub init_assortment_items {
 
 849   # this init is used while saving and whenever assortments change dynamically
 
 853   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 854   foreach my $assortment_item ( @{$assortment_items} ) {
 
 855     next unless $assortment_item->{parts_id};
 
 857     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 858     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 859                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 860                                           charge        => $assortment_item->{charge},
 
 861                                           unit          => $assortment_item->{unit} || $part->unit,
 
 862                                           position      => $position,
 
 870 sub init_makemodels {
 
 874   my @makemodel_array = ();
 
 875   my $makemodels = delete($::form->{makemodels}) || [];
 
 877   foreach my $makemodel ( @{$makemodels} ) {
 
 878     next unless $makemodel->{make};
 
 880     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 881                                     id        => $makemodel->{id},
 
 882                                     make      => $makemodel->{make},
 
 883                                     model     => $makemodel->{model} || '',
 
 884                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 885                                     sortorder => $position,
 
 886                                   ) or die "Can't create mm";
 
 887     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 888     push(@makemodel_array, $mm);
 
 890   return \@makemodel_array;
 
 893 sub init_assembly_items {
 
 897   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 898   foreach my $assembly_item ( @{$assembly_items} ) {
 
 899     next unless $assembly_item->{parts_id};
 
 901     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 902     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 903                                    bom         => $assembly_item->{bom},
 
 904                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 905                                    position    => $position,
 
 912 sub init_all_warehouses {
 
 914   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 917 sub init_all_languages {
 
 918   SL::DB::Manager::Language->get_all_sorted;
 
 921 sub init_all_partsgroups {
 
 923   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 926 sub init_all_buchungsgruppen {
 
 928   if ( $self->part->orphaned ) {
 
 929     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 931     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 937   if ( $self->part->orphaned ) {
 
 938     return SL::DB::Manager::Unit->get_all_sorted;
 
 940     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 944 sub init_all_payment_terms {
 
 946   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
 949 sub init_all_price_factors {
 
 950   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 953 sub init_all_pricegroups {
 
 954   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 957 # model used to filter/display the parts in the multi-items dialog
 
 958 sub init_multi_items_models {
 
 959   SL::Controller::Helper::GetModels->new(
 
 962     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
 963     disable_plugin => 'paginated',
 
 964     source         => $::form->{multi_items},
 
 970       partnumber  => t8('Partnumber'),
 
 971       description => t8('Description')}
 
 975 sub init_parts_classification_filter {
 
 976   return [] unless $::form->{parts_classification_type};
 
 978   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
 979   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
 981   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
 984 # simple checks to run on $::form before saving
 
 986 sub form_check_part_description_exists {
 
 989   return 1 if $::form->{part}{description};
 
 991   $self->js->flash('error', t8('Part Description missing!'))
 
 992            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 993            ->focus('#part_description');
 
 997 sub form_check_assortment_items_exist {
 
1000   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1001   # skip item check for existing assortments that have been used
 
1002   return 1 if ($self->part->id and !$self->part->orphaned);
 
1004   # new or orphaned parts must have items in $::form->{assortment_items}
 
1005   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1006     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1007              ->focus('#add_assortment_item_name')
 
1008              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1014 sub form_check_assortment_items_unique {
 
1017   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1019   my %duplicate_elements;
 
1021   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1022     $duplicate_elements{$_}++ if $count{$_}++;
 
1025   if ( keys %duplicate_elements ) {
 
1026     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1027              ->flash('error', t8('There are duplicate assortment items'));
 
1033 sub form_check_assembly_items_exist {
 
1036   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1038   # skip item check for existing assembly that have been used
 
1039   return 1 if ($self->part->id and !$self->part->orphaned);
 
1041   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1042     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1043              ->focus('#add_assembly_item_name')
 
1044              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1050 sub form_check_partnumber_is_unique {
 
1053   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1054     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1056       $self->js->flash('error', t8('The partnumber already exists!'))
 
1057                ->focus('#part_description');
 
1064 # general checking functions
 
1067   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1073   $self->form_check_part_description_exists || return 0;
 
1074   $self->form_check_assortment_items_exist  || return 0;
 
1075   $self->form_check_assortment_items_unique || return 0;
 
1076   $self->form_check_assembly_items_exist    || return 0;
 
1077   $self->form_check_partnumber_is_unique    || return 0;
 
1082 sub check_has_valid_part_type {
 
1083   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1086 sub render_assortment_items_to_html {
 
1087   my ($self, $assortment_items, $number_of_items) = @_;
 
1089   my $position = $number_of_items + 1;
 
1091   foreach my $ai (@$assortment_items) {
 
1092     $html .= $self->p->render('part/_assortment_row',
 
1093                               PART     => $self->part,
 
1094                               orphaned => $self->orphaned,
 
1096                               listrow  => $position % 2 ? 1 : 0,
 
1097                               position => $position, # for legacy assemblies
 
1104 sub render_assembly_items_to_html {
 
1105   my ($self, $assembly_items, $number_of_items) = @_;
 
1107   my $position = $number_of_items + 1;
 
1109   foreach my $ai (@{$assembly_items}) {
 
1110     $html .= $self->p->render('part/_assembly_row',
 
1111                               PART     => $self->part,
 
1112                               orphaned => $self->orphaned,
 
1114                               listrow  => $position % 2 ? 1 : 0,
 
1115                               position => $position, # for legacy assemblies
 
1122 sub parse_add_items_to_objects {
 
1123   my ($self, %params) = @_;
 
1124   my $part_type = $params{part_type};
 
1125   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1126   my $position = $params{position} || 1;
 
1128   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1131   foreach my $item ( @add_items ) {
 
1132     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1134     if ( $part_type eq 'assortment' ) {
 
1135        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1136                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1137                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1138                                          position      => $position,
 
1139                                         ) or die "Can't create AssortmentItem from item";
 
1140     } elsif ( $part_type eq 'assembly' ) {
 
1141       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1142                                  # id          => $self->assembly->id, # will be set on save
 
1143                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1144                                  bom         => 0, # default when adding: no bom
 
1145                                  position    => $position,
 
1148       die "part_type must be assortment or assembly";
 
1150     push(@item_objects, $ai);
 
1154   return \@item_objects;
 
1157 sub _setup_form_action_bar {
 
1160   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1162   for my $bar ($::request->layout->get('actionbar')) {
 
1167           call      => [ 'kivi.Part.save' ],
 
1168           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1169           accesskey => 'enter',
 
1173           call     => [ 'kivi.Part.use_as_new' ],
 
1174           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1175                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1178       ], # end of combobox "Save"
 
1182         call     => [ 'kivi.Part.delete' ],
 
1183         confirm  => t8('Do you really want to delete this object?'),
 
1184         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1185                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1186                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1194         call     => [ 'kivi.Part.open_history_popup' ],
 
1195         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1196                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1211 SL::Controller::Part - Part CRUD controller
 
1215 Controller for adding/editing/saving/deleting parts.
 
1217 All the relations are loaded at once and saving the part, adding a history
 
1218 entry and saving CVars happens inside one transaction.  When saving the old
 
1219 relations are deleted and written as new to the database.
 
1221 Relations for parts:
 
1229 =item assembly items
 
1231 =item assortment items
 
1239 There are 4 different part types:
 
1245 The "default" part type.
 
1247 inventory_accno_id is set.
 
1251 Services can't be stocked.
 
1253 inventory_accno_id isn't set.
 
1257 Assemblies consist of other parts, services, assemblies or assortments. They
 
1258 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1259 have to make them, which reduces the stock by its respective components. Once
 
1260 an assembly item has been created there is currently no way to "disassemble" it
 
1261 again. An assembly item can appear several times in one assembly. An assmbly is
 
1262 sold as one item with a defined sellprice and lastcost. If the component prices
 
1263 change the assortment price remains the same. The assembly items may be printed
 
1264 in a record if the item's "bom" is set.
 
1268 Similar to assembly, but each assortment item may only appear once per
 
1269 assortment. When selling an assortment the assortment items are added to the
 
1270 record together with the assortment, which is added with sellprice 0.
 
1272 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1273 determined by the sum of the current assortment item prices when the assortment
 
1274 is added to a record. This also means that price rules and customer discounts
 
1275 will be applied to the assortment items.
 
1277 Once the assortment items have been added they may be modified or deleted, just
 
1278 as if they had been added manually, the individual assortment items aren't
 
1279 linked to the assortment or the other assortment items in any way.
 
1287 =item C<action_add_part>
 
1289 =item C<action_add_service>
 
1291 =item C<action_add_assembly>
 
1293 =item C<action_add_assortment>
 
1295 =item C<action_add PART_TYPE>
 
1297 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1298 parameter part_type as an action. Example:
 
1300   controller.pl?action=Part/add&part_type=service
 
1302 =item C<action_add_from_record>
 
1304 When adding new items to records they can be created on the fly if the entered
 
1305 partnumber or description doesn't exist yet. After being asked what part type
 
1306 the new item should have the user is redirected to the correct edit page.
 
1308 Depending on whether the item was added from a sales or a purchase record, only
 
1309 the relevant part classifications should be selectable for new item, so this
 
1310 parameter is passed on via a hidden parts_classification_type in the new_item
 
1313 =item C<action_save>
 
1315 Saves the current part and then reloads the edit page for the part.
 
1317 =item C<action_use_as_new>
 
1319 Takes the information from the current part, plus any modifications made on the
 
1320 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1321 set empty, so a new partnumber from the number range will be used if the user
 
1322 doesn't enter one manually.
 
1324 Unsaved changes to the original part aren't updated.
 
1326 The part type cannot be changed in this way.
 
1328 =item C<action_delete>
 
1330 Deletes the current part and then redirects to the main page, there is no
 
1333 The delete button only appears if the part is 'orphaned', according to
 
1334 SL::DB::Part orphaned.
 
1336 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1337 the inventory, or is part of an assembly or assortment.
 
1339 If the part is deleted its relations prices, makdemodel, assembly,
 
1340 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1342 Before this controller items that appeared in inventory didn't count as
 
1343 orphaned and could be deleted and the inventory entries were also deleted, this
 
1344 "feature" hasn't been implemented.
 
1346 =item C<action_edit part.id>
 
1348 Load and display a part for editing.
 
1350   controller.pl?action=Part/edit&part.id=12345
 
1352 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1356 =head1 BUTTON ACTIONS
 
1362 Opens a popup displaying all the history entries. Once a new history controller
 
1363 is written the button could link there instead, with the part already selected.
 
1371 =item C<action_update_item_totals>
 
1373 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1374 amount of an item changes. The sum of all sellprices and lastcosts is
 
1375 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1377 =item C<action_add_assortment_item>
 
1379 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1381 If the item already exists in the assortment the item isn't added and a Flash
 
1384 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1385 after adding each new item, add the new object to the item objects that were
 
1386 already parsed, calculate totals via a dummy part then update the row and the
 
1389 =item C<action_add_assembly_item>
 
1391 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1393 If the item already exists in the assembly a flash info is generated, but the
 
1396 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1397 after adding each new item, add the new object to the item objects that were
 
1398 already parsed, calculate totals via a dummy part then update the row and the
 
1401 =item C<action_add_multi_assortment_items>
 
1403 Parses the items to be added from the form generated by the multi input and
 
1404 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1405 assortment items are renumbered and the sums recalculated via
 
1406 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1408 =item C<action_add_multi_assembly_items>
 
1410 Parses the items to be added from the form generated by the multi input and
 
1411 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1412 assembly items are renumbered and the sums recalculated via
 
1413 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1415 =item C<action_show_multi_items_dialog>
 
1417 =item C<action_multi_items_update_result>
 
1419 =item C<action_add_makemodel_row>
 
1421 Add a new makemodel row with the vendor that was selected via the vendor
 
1424 Checks the already existing makemodels and warns if a row with that vendor
 
1425 already exists. Currently it is possible to have duplicate vendor rows.
 
1427 =item C<action_reorder_items>
 
1429 Sorts the item table for assembly or assortment items.
 
1431 =item C<action_warehouse_changed>
 
1435 =head1 ACTIONS part picker
 
1439 =item C<action_ajax_autocomplete>
 
1441 =item C<action_test_page>
 
1443 =item C<action_part_picker_search>
 
1445 =item C<action_part_picker_result>
 
1447 =item C<action_show>
 
1457 Calls some simple checks that test the submitted $::form for obvious errors.
 
1458 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1460 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1461 some cases extra actions are taken, e.g. if the part description is missing the
 
1462 basic data tab is selected and the description input field is focussed.
 
1468 =item C<form_check_part_description_exists>
 
1470 =item C<form_check_assortment_items_exist>
 
1472 =item C<form_check_assortment_items_unique>
 
1474 =item C<form_check_assembly_items_exist>
 
1476 =item C<form_check_partnumber_is_unique>
 
1480 =head1 HELPER FUNCTIONS
 
1486 When submitting the form for saving, parses the transmitted form. Expects the
 
1490  $::form->{makemodels}
 
1491  $::form->{translations}
 
1493  $::form->{assemblies}
 
1494  $::form->{assortments}
 
1496 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1498 =item C<recalc_item_totals %params>
 
1500 Helper function for calculating the total lastcost and sellprice for assemblies
 
1501 or assortments according to their items, which are parsed from the current
 
1504 Is called whenever the qty of an item is changed or items are deleted.
 
1508 * part_type : 'assortment' or 'assembly' (mandatory)
 
1510 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1512 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1514 Doesn't work for recursive items.
 
1518 =head1 GET SET INITS
 
1520 There are get_set_inits for
 
1528 which parse $::form and automatically create an array of objects.
 
1530 These inits are used during saving and each time a new element is added.
 
1534 =item C<init_makemodels>
 
1536 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1537 $self->part->makemodels, ready to be saved.
 
1539 Used for saving parts and adding new makemodel rows.
 
1541 =item C<parse_add_items_to_objects PART_TYPE>
 
1543 Parses the resulting form from either the part-picker submit or the multi-item
 
1544 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1545 can be rendered via C<render_assortment_items_to_html> or
 
1546 C<render_assembly_items_to_html>.
 
1548 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1549 Optional param: position (used for numbering and listrow class)
 
1551 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1553 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1554 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1555 assortment items are added.
 
1557 =item C<parse_form_makemodels>
 
1559 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1560 remembers when the lastcost for that vendor changed the last time.
 
1562 So the original values are cloned and remembered, so we can compare if lastcost
 
1563 was changed in $::form, and keep or update lastupdate.
 
1565 lastcost isn't updated until the first time it was saved with a value, until
 
1568 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1569 makemodel entries exist or not.
 
1571 We still need init_makemodels for when we open the part for editing.
 
1581 It should be possible to jump to the edit page in a specific tab
 
1585 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1586 back to the order again afterwards.
 
1590 Support units when adding assembly items or assortment items. Currently the
 
1591 default unit of the item is always used.
 
1595 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1596 consists of other assemblies.
 
1602 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>