+sub action_add_service {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_service );
+  $self->add;
+};
+
+sub action_add_assembly {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_assembly );
+  $self->add;
+};
+
+sub action_add_assortment {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_assortment );
+  $self->add;
+};
+
+sub action_add_from_record {
+  my ($self) = @_;
+
+  check_has_valid_part_type($::form->{part}{part_type});
+
+  die 'parts_classification_type must be "sales" or "purchases"'
+    unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
+
+  $self->parse_form;
+  $self->add;
+}
+
+sub action_add {
+  my ($self) = @_;
+
+  check_has_valid_part_type($::form->{part_type});
+
+  $self->action_add_part       if $::form->{part_type} eq 'part';
+  $self->action_add_service    if $::form->{part_type} eq 'service';
+  $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
+  $self->action_add_assortment if $::form->{part_type} eq 'assortment';
+};
+
+sub action_save {
+  my ($self, %params) = @_;
+
+  # checks that depend only on submitted $::form
+  $self->check_form or return $self->js->render;
+
+  my $is_new = !$self->part->id; # $ part gets loaded here
+
+  # check that the part hasn't been modified
+  unless ( $is_new ) {
+    $self->check_part_not_modified or
+      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;
+  }
+
+  if (    $is_new
+       && $::form->{part}{partnumber}
+       && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
+     ) {
+    return $self->js->error(t8('The partnumber is already being used'))->render;
+  }
+
+  $self->parse_form;
+
+  my @errors = $self->part->validate;
+  return $self->js->error(@errors)->render if @errors;
+
+  # $self->part has been loaded, parsed and validated without errors and is ready to be saved
+  $self->part->db->with_transaction(sub {
+
+    if ( $params{save_as_new} ) {
+      $self->part( $self->part->clone_and_reset_deep );
+      $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
+    };
+
+    $self->part->save(cascade => 1);
+
+    SL::DB::History->new(
+      trans_id    => $self->part->id,
+      snumbers    => 'partnumber_' . $self->part->partnumber,
+      employee_id => SL::DB::Manager::Employee->current->id,
+      what_done   => 'part',
+      addition    => 'SAVED',
+    )->save();
+
+    CVar->save_custom_variables(
+      dbh           => $self->part->db->dbh,
+      module        => 'IC',
+      trans_id      => $self->part->id,
+      variables     => $::form, # $::form->{cvar} would be nicer
+      save_validity => 1,
+    );
+
+    1;
+  }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
+
+  ;
+  flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
+
+  if ( $::form->{callback} ) {
+    $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
+
+  } else {
+    # default behaviour after save: reload item, this also resets last_modification!
+    $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
+  }
+}
+
+sub action_save_as_new {
+  my ($self) = @_;
+  $self->action_save(save_as_new=>1);
+}
+
+sub action_delete {
+  my ($self) = @_;
+
+  my $db = $self->part->db; # $self->part has a get_set_init on $::form
+
+  my $partnumber = $self->part->partnumber; # remember for history log
+
+  $db->do_transaction(
+    sub {
+
+      # delete part, together with relationships that don't already
+      # have an ON DELETE CASCADE, e.g. makemodel and translation.
+      $self->part->delete(cascade => 1);
+
+      SL::DB::History->new(
+        trans_id    => $self->part->id,
+        snumbers    => 'partnumber_' . $partnumber,
+        employee_id => SL::DB::Manager::Employee->current->id,
+        what_done   => 'part',
+        addition    => 'DELETED',
+      )->save();
+      1;
+  }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
+
+  flash_later('info', t8('The item has been deleted.'));
+  if ( $::form->{callback} ) {
+    $self->redirect_to($::form->unescape($::form->{callback}));
+  } else {
+    $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
+  }
+}
+
+sub action_use_as_new {
+  my ($self, %params) = @_;
+
+  my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
+  $::form->{oldpartnumber} = $oldpart->partnumber;
+
+  $self->part($oldpart->clone_and_reset_deep);
+  $self->parse_form;
+  $self->part->partnumber(undef);
+
+  $self->render_form;
+}
+
+sub action_edit {
+  my ($self, %params) = @_;
+
+  $self->render_form;
+}
+
+sub render_form {
+  my ($self, %params) = @_;
+
+  $self->_set_javascript;
+  $self->_setup_form_action_bar;
+
+  my (%assortment_vars, %assembly_vars);
+  %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
+  %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
+
+  $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
+
+  CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
+    if (scalar @{ $params{CUSTOM_VARIABLES} });
+
+  my %title_hash = ( part       => t8('Edit Part'),
+                     assembly   => t8('Edit Assembly'),
+                     service    => t8('Edit Service'),
+                     assortment => t8('Edit Assortment'),
+                   );
+
+  $self->part->prices([])       unless $self->part->prices;
+  $self->part->translations([]) unless $self->part->translations;
+
+  $self->render(
+    'part/form',
+    title             => $title_hash{$self->part->part_type},
+    %assortment_vars,
+    %assembly_vars,
+    translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
+    prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
+    oldpartnumber     => $::form->{oldpartnumber},
+    old_id            => $::form->{old_id},
+    %params,
+  );
+}
+
+sub action_history {
+  my ($self) = @_;
+
+  my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
+  $_[0]->render('part/history', { layout => 0 },
+                                  history_entries => $history_entries);
+}
+
+sub action_inventory {
+  my ($self) = @_;
+
+  $::auth->assert('warehouse_contents');
+
+  $self->stock_amounts($self->part->get_simple_stock_sql);
+  $self->journal($self->part->get_mini_journal);
+
+  $_[0]->render('part/_inventory_data', { layout => 0 });
+};
+
+sub action_update_item_totals {
+  my ($self) = @_;
+
+  my $part_type = $::form->{part_type};
+  die unless $part_type =~ /^(assortment|assembly)$/;
+
+  my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
+  my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
+
+  my $sum_diff      = $sellprice_sum-$lastcost_sum;
+
+  $self->js
+    ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
+    ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
+    ->no_flash_clear->render();
+}
+
+sub action_add_multi_assortment_items {
+  my ($self) = @_;
+
+  my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
+  my $html         = $self->render_assortment_items_to_html($item_objects);
+
+  $self->js->run('kivi.Part.close_picker_dialogs')
+           ->append('#assortment_rows', $html)
+           ->run('kivi.Part.renumber_positions')
+           ->run('kivi.Part.assortment_recalc')
+           ->render();
+}
+
+sub action_add_multi_assembly_items {
+  my ($self) = @_;
+
+  my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
+  my @checked_objects;
+  foreach my $item (@{$item_objects}) {
+    my $errstr = validate_assembly($item->part,$self->part);
+    $self->js->flash('error',$errstr) if     $errstr;
+    push (@checked_objects,$item)     unless $errstr;
+  }
+
+  my $html = $self->render_assembly_items_to_html(\@checked_objects);
+
+  $self->js->run('kivi.Part.close_picker_dialogs')
+           ->append('#assembly_rows', $html)
+           ->run('kivi.Part.renumber_positions')
+           ->run('kivi.Part.assembly_recalc')
+           ->render();
+}
+
+sub action_add_assortment_item {
+  my ($self, %params) = @_;
+
+  validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
+
+  carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
+
+  my $add_item_id = $::form->{add_items}->[0]->{parts_id};
+  if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
+    return $self->js->flash('error', t8("This part has already been added."))->render;
+  };
+
+  my $number_of_items = scalar @{$self->assortment_items};
+  my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
+  my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
+
+  push(@{$self->assortment_items}, @{$item_objects});
+  my $part = SL::DB::Part->new(part_type => 'assortment');
+  $part->assortment_items(@{$self->assortment_items});
+  my $items_sellprice_sum = $part->items_sellprice_sum;
+  my $items_lastcost_sum  = $part->items_lastcost_sum;
+  my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
+
+  $self->js
+    ->append('#assortment_rows'        , $html)  # append in tbody
+    ->val('.add_assortment_item_input' , '')
+    ->run('kivi.Part.focus_last_assortment_input')
+    ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
+    ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
+    ->render;
+}
+
+sub action_add_assembly_item {
+  my ($self) = @_;
+
+  validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
+
+  carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
+
+  my $add_item_id = $::form->{add_items}->[0]->{parts_id};
+
+  my $duplicate_warning = 0; # duplicates are allowed, just warn
+  if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
+    $duplicate_warning++;
+  };
+
+  my $number_of_items = scalar @{$self->assembly_items};
+  my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
+  if ($add_item_id ) {
+    foreach my $item (@{$item_objects}) {
+      my $errstr = validate_assembly($item->part,$self->part);
+      return $self->js->flash('error',$errstr)->render if $errstr;
+    }
+  }
+
+
+  my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
+
+  $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
+
+  push(@{$self->assembly_items}, @{$item_objects});
+  my $part = SL::DB::Part->new(part_type => 'assembly');
+  $part->assemblies(@{$self->assembly_items});
+  my $items_sellprice_sum = $part->items_sellprice_sum;
+  my $items_lastcost_sum  = $part->items_lastcost_sum;
+  my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
+
+  $self->js
+    ->append('#assembly_rows', $html)  # append in tbody
+    ->val('.add_assembly_item_input' , '')
+    ->run('kivi.Part.focus_last_assembly_input')
+    ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
+    ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
+    ->render;
+}
+
+sub action_show_multi_items_dialog {
+  my ($self) = @_;
+
+  my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
+  $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
+  $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
+
+  $_[0]->render('part/_multi_items_dialog', { layout => 0 },
+                all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
+                search_term     => $search_term
+  );
+}
+
+sub action_multi_items_update_result {
+  my $max_count = 100;
+
+  my $count = $_[0]->multi_items_models->count;
+
+  if ($count == 0) {
+    my $text = escape($::locale->text('No results.'));
+    $_[0]->render($text, { layout => 0 });
+  } elsif ($count > $max_count) {
+    my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
+    $_[0]->render($text, { layout => 0 });
+  } else {
+    my $multi_items = $_[0]->multi_items_models->get;
+    $_[0]->render('part/_multi_items_result', { layout => 0 },
+                  multi_items => $multi_items);
+  }
+}
+
+sub action_add_makemodel_row {
+  my ($self) = @_;
+
+  my $vendor_id = $::form->{add_makemodel};
+
+  my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
+    return $self->js->error(t8("No vendor selected or found!"))->render;
+
+  if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
+    $self->js->flash('info', t8("This vendor has already been added."));
+  };
+
+  my $position = scalar @{$self->makemodels} + 1;
+
+  my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
+                                  make        => $vendor->id,
+                                  model       => '',
+                                  lastcost    => 0,
+                                  sortorder    => $position,
+                                 ) or die "Can't create MakeModel object";
+
+  my $row_as_html = $self->p->render('part/_makemodel_row',
+                                     makemodel => $mm,
+                                     listrow   => $position % 2 ? 0 : 1,
+  );
+
+  # after selection focus on the model field in the row that was just added
+  $self->js
+    ->append('#makemodel_rows', $row_as_html)  # append in tbody
+    ->val('.add_makemodel_input', '')
+    ->run('kivi.Part.focus_last_makemodel_input')
+    ->render;
+}
+
+sub action_add_customerprice_row {
+  my ($self) = @_;
+
+  my $customer_id = $::form->{add_customerprice};
+
+  my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
+    or return $self->js->error(t8("No customer selected or found!"))->render;
+
+  if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
+    $self->js->flash('info', t8("This customer has already been added."));
+  }
+
+  my $position = scalar @{ $self->customerprices } + 1;
+
+  my $cu = SL::DB::PartCustomerPrice->new(
+                      customer_id         => $customer->id,
+                      customer_partnumber => '',
+                      price               => 0,
+                      sortorder           => $position,
+  ) or die "Can't create Customerprice object";
+
+  my $row_as_html = $self->p->render(
+                                     'part/_customerprice_row',
+                                      customerprice => $cu,
+                                      listrow       => $position % 2 ? 0
+                                                                     : 1,
+  );
+
+  $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
+           ->val('.add_customerprice_input', '')
+           ->run('kivi.Part.focus_last_customerprice_input')->render;
+}
+
+sub action_reorder_items {
+  my ($self) = @_;
+
+  my $part_type = $::form->{part_type};
+
+  my %sort_keys = (
+    partnumber  => sub { $_[0]->part->partnumber },
+    description => sub { $_[0]->part->description },
+    qty         => sub { $_[0]->qty },
+    sellprice   => sub { $_[0]->part->sellprice },
+    lastcost    => sub { $_[0]->part->lastcost },
+    partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
+  );
+
+  my $method = $sort_keys{$::form->{order_by}};
+
+  my @items;
+  if ($part_type eq 'assortment') {
+    @items = @{ $self->assortment_items };
+  } else {
+    @items = @{ $self->assembly_items };
+  };
+
+  my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
+  if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
+    if ($::form->{sort_dir}) {
+      @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
+    }
+  } else {
+    if ($::form->{sort_dir}) {
+      @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
+    }
+  };
+
+  $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
+}
+
+sub action_warehouse_changed {
+  my ($self) = @_;
+
+  if ($::form->{warehouse_id} ) {
+    $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
+    die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
+
+    if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
+      $self->bin($self->warehouse->bins_sorted->[0]);
+      $self->js
+        ->html('#bin', $self->build_bin_select)
+        ->focus('#part_bin_id');
+      return $self->js->render;
+    }
+  }
+
+  # no warehouse was selected, empty the bin field and reset the id
+  $self->js
+       ->val('#part_bin_id', undef)
+       ->html('#bin', '');
+
+  return $self->js->render;
+}