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);
 
  21 use Rose::Object::MakeMethods::Generic (
 
  22   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  25                                   assortment assortment_items assembly assembly_items
 
  26                                   all_pricegroups all_translations all_partsgroups all_units
 
  27                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  28                                   all_languages all_units all_price_factors) ],
 
  29   'scalar'                => [ qw(warehouse bin) ],
 
  33 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  34                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  36 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  38 # actions for editing parts
 
  41   my ($self, %params) = @_;
 
  43   $self->part( SL::DB::Part->new_part );
 
  47 sub action_add_service {
 
  48   my ($self, %params) = @_;
 
  50   $self->part( SL::DB::Part->new_service );
 
  54 sub action_add_assembly {
 
  55   my ($self, %params) = @_;
 
  57   $self->part( SL::DB::Part->new_assembly );
 
  61 sub action_add_assortment {
 
  62   my ($self, %params) = @_;
 
  64   $self->part( SL::DB::Part->new_assortment );
 
  71   check_has_valid_part_type($::form->{part_type});
 
  73   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  74   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  75   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  76   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  80   my ($self, %params) = @_;
 
  82   # checks that depend only on submitted $::form
 
  83   $self->check_form or return $self->js->render;
 
  85   my $is_new = !$self->part->id; # $ part gets loaded here
 
  87   # check that the part hasn't been modified
 
  89     $self->check_part_not_modified or
 
  90       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;
 
  93   if ( $is_new and !$::form->{part}{partnumber} ) {
 
  94     $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
 
  99   my @errors = $self->part->validate;
 
 100   return $self->js->error(@errors)->render if @errors;
 
 102   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 103   $self->part->db->with_transaction(sub {
 
 105     if ( $params{save_as_new} ) {
 
 106       $self->part( $self->part->clone_and_reset_deep );
 
 107       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 110     $self->part->save(cascade => 1);
 
 112     SL::DB::History->new(
 
 113       trans_id    => $self->part->id,
 
 114       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 115       employee_id => SL::DB::Manager::Employee->current->id,
 
 120     CVar->save_custom_variables(
 
 121         dbh          => $self->part->db->dbh,
 
 123         trans_id     => $self->part->id,
 
 124         variables    => $::form, # $::form->{cvar} would be nicer
 
 129   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 131   flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
 
 133   # reload item, this also resets last_modification!
 
 134   $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 137 sub action_save_as_new {
 
 139   $self->action_save(save_as_new=>1);
 
 145   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 147   my $partnumber = $self->part->partnumber; # remember for history log
 
 152       # delete part, together with relationships that don't already
 
 153       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 154       $self->part->delete(cascade => 1);
 
 156       SL::DB::History->new(
 
 157         trans_id    => $self->part->id,
 
 158         snumbers    => 'partnumber_' . $partnumber,
 
 159         employee_id => SL::DB::Manager::Employee->current->id,
 
 161         addition    => 'DELETED',
 
 164   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 166   flash_later('info', t8('The item has been deleted.'));
 
 167   my @redirect_params = (
 
 168     controller => 'controller.pl',
 
 169     action => 'LoginScreen/user_login'
 
 171   $self->redirect_to(@redirect_params);
 
 174 sub action_use_as_new {
 
 175   my ($self, %params) = @_;
 
 177   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 178   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 180   $self->part($oldpart->clone_and_reset_deep);
 
 182   $self->part->partnumber(undef);
 
 188   my ($self, %params) = @_;
 
 194   my ($self, %params) = @_;
 
 196   $self->_set_javascript;
 
 197   $self->_setup_form_action_bar;
 
 199   my (%assortment_vars, %assembly_vars);
 
 200   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 201   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 203   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 205   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 206     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 208   my %title_hash = ( part       => t8('Edit Part'),
 
 209                      assembly   => t8('Edit Assembly'),
 
 210                      service    => t8('Edit Service'),
 
 211                      assortment => t8('Edit Assortment'),
 
 214   $self->part->prices([])       unless $self->part->prices;
 
 215   $self->part->translations([]) unless $self->part->translations;
 
 219     title             => $title_hash{$self->part->part_type},
 
 222     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 223     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 224     oldpartnumber     => $::form->{oldpartnumber},
 
 225     old_id            => $::form->{old_id},
 
 233   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 234   $_[0]->render('part/history', { layout => 0 },
 
 235                                   history_entries => $history_entries);
 
 238 sub action_update_item_totals {
 
 241   my $part_type = $::form->{part_type};
 
 242   die unless $part_type =~ /^(assortment|assembly)$/;
 
 244   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 245   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 247   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 250     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 251     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 252     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 253     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 254     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 255     ->no_flash_clear->render();
 
 258 sub action_add_multi_assortment_items {
 
 261   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 262   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 264   $self->js->run('kivi.Part.close_picker_dialogs')
 
 265            ->append('#assortment_rows', $html)
 
 266            ->run('kivi.Part.renumber_positions')
 
 267            ->run('kivi.Part.assortment_recalc')
 
 271 sub action_add_multi_assembly_items {
 
 274   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 276   foreach my $item (@{$item_objects}) {
 
 277     my $errstr = validate_assembly($item->part,$self->part);
 
 278     $self->js->flash('error',$errstr) if     $errstr;
 
 279     push (@checked_objects,$item)     unless $errstr;
 
 282   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 284   $self->js->run('kivi.Part.close_picker_dialogs')
 
 285            ->append('#assembly_rows', $html)
 
 286            ->run('kivi.Part.renumber_positions')
 
 287            ->run('kivi.Part.assembly_recalc')
 
 291 sub action_add_assortment_item {
 
 292   my ($self, %params) = @_;
 
 294   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 296   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 298   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 299   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 300     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 303   my $number_of_items = scalar @{$self->assortment_items};
 
 304   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 305   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 307   push(@{$self->assortment_items}, @{$item_objects});
 
 308   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 309   $part->assortment_items(@{$self->assortment_items});
 
 310   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 311   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 312   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 315     ->append('#assortment_rows'        , $html)  # append in tbody
 
 316     ->val('.add_assortment_item_input' , '')
 
 317     ->run('kivi.Part.focus_last_assortment_input')
 
 318     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 319     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 320     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 321     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 322     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 326 sub action_add_assembly_item {
 
 329   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 331   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 333   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 335   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 336   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 337     $duplicate_warning++;
 
 340   my $number_of_items = scalar @{$self->assembly_items};
 
 341   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 343     foreach my $item (@{$item_objects}) {
 
 344       my $errstr = validate_assembly($item->part,$self->part);
 
 345       return $self->js->flash('error',$errstr)->render if $errstr;
 
 350   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 352   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 354   push(@{$self->assembly_items}, @{$item_objects});
 
 355   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 356   $part->assemblies(@{$self->assembly_items});
 
 357   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 358   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 359   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 362     ->append('#assembly_rows', $html)  # append in tbody
 
 363     ->val('.add_assembly_item_input' , '')
 
 364     ->run('kivi.Part.focus_last_assembly_input')
 
 365     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 366     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 367     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 368     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 369     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 373 sub action_show_multi_items_dialog {
 
 374   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 375     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 379 sub action_multi_items_update_result {
 
 382   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 384   my $count = $_[0]->multi_items_models->count;
 
 387     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 388     $_[0]->render($text, { layout => 0 });
 
 389   } elsif ($count > $max_count) {
 
 390     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 391     $_[0]->render($text, { layout => 0 });
 
 393     my $multi_items = $_[0]->multi_items_models->get;
 
 394     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 395                   multi_items => $multi_items);
 
 399 sub action_add_makemodel_row {
 
 402   my $vendor_id = $::form->{add_makemodel};
 
 404   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 405     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 407   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 408     $self->js->flash('info', t8("This vendor has already been added."));
 
 411   my $position = scalar @{$self->makemodels} + 1;
 
 413   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 417                                   sortorder    => $position,
 
 418                                  ) or die "Can't create MakeModel object";
 
 420   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 422                                      listrow   => $position % 2 ? 0 : 1,
 
 425   # after selection focus on the model field in the row that was just added
 
 427     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 428     ->val('.add_makemodel_input', '')
 
 429     ->run('kivi.Part.focus_last_makemodel_input')
 
 433 sub action_reorder_items {
 
 436   my $part_type = $::form->{part_type};
 
 439     partnumber  => sub { $_[0]->part->partnumber },
 
 440     description => sub { $_[0]->part->description },
 
 441     qty         => sub { $_[0]->qty },
 
 442     sellprice   => sub { $_[0]->part->sellprice },
 
 443     lastcost    => sub { $_[0]->part->lastcost },
 
 444     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 447   my $method = $sort_keys{$::form->{order_by}};
 
 450   if ($part_type eq 'assortment') {
 
 451     @items = @{ $self->assortment_items };
 
 453     @items = @{ $self->assembly_items };
 
 456   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 457   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 458     if ($::form->{sort_dir}) {
 
 459       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 461       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 464     if ($::form->{sort_dir}) {
 
 465       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 467       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 471   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 474 sub action_warehouse_changed {
 
 477   if ($::form->{warehouse_id} ) {
 
 478     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 479     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 481     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 482       $self->bin($self->warehouse->bins->[0]);
 
 484         ->html('#bin', $self->build_bin_select)
 
 485         ->focus('#part_bin_id');
 
 486       return $self->js->render;
 
 490   # no warehouse was selected, empty the bin field and reset the id
 
 492        ->val('#part_bin_id', undef)
 
 495   return $self->js->render;
 
 498 sub action_ajax_autocomplete {
 
 499   my ($self, %params) = @_;
 
 501   # if someone types something, and hits enter, assume he entered the full name.
 
 502   # if something matches, treat that as sole match
 
 503   # since we need a second get models instance with different filters for that,
 
 504   # we only modify the original filter temporarily in place
 
 505   if ($::form->{prefer_exact}) {
 
 506     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 508     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 511       paginated    => { per_page => 2 },
 
 512       with_objects => [ qw(unit_obj classification) ],
 
 515     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 516       $self->parts($exact_matches);
 
 522      value       => $_->displayable_name,
 
 523      label       => $_->displayable_name,
 
 525      partnumber  => $_->partnumber,
 
 526      description => $_->description,
 
 527      part_type   => $_->part_type,
 
 529      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 531   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 533   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 536 sub action_test_page {
 
 537   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 540 sub action_part_picker_search {
 
 541   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 544 sub action_part_picker_result {
 
 545   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 551   if ($::request->type eq 'json') {
 
 556       $part_hash          = $self->part->as_tree;
 
 557       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 560     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 565 sub validate_add_items {
 
 566   scalar @{$::form->{add_items}};
 
 569 sub prepare_assortment_render_vars {
 
 572   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 573                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 574                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 576   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 581 sub prepare_assembly_render_vars {
 
 584   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 585                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 586                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 588   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 596   check_has_valid_part_type($self->part->part_type);
 
 598   $self->_set_javascript;
 
 599   $self->_setup_form_action_bar;
 
 601   my %title_hash = ( part       => t8('Add Part'),
 
 602                      assembly   => t8('Add Assembly'),
 
 603                      service    => t8('Add Service'),
 
 604                      assortment => t8('Add Assortment'),
 
 609     title => $title_hash{$self->part->part_type},
 
 614 sub _set_javascript {
 
 616   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
 
 617   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 620 sub recalc_item_totals {
 
 621   my ($self, %params) = @_;
 
 623   if ( $params{part_type} eq 'assortment' ) {
 
 624     return 0 unless scalar @{$self->assortment_items};
 
 625   } elsif ( $params{part_type} eq 'assembly' ) {
 
 626     return 0 unless scalar @{$self->assembly_items};
 
 628     carp "can only calculate sum for assortments and assemblies";
 
 631   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 632   if ( $part->is_assortment ) {
 
 633     $part->assortment_items( @{$self->assortment_items} );
 
 634     if ( $params{price_type} eq 'lastcost' ) {
 
 635       return $part->items_lastcost_sum;
 
 637       if ( $params{pricegroup_id} ) {
 
 638         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 640         return $part->items_sellprice_sum;
 
 643   } elsif ( $part->is_assembly ) {
 
 644     $part->assemblies( @{$self->assembly_items} );
 
 645     if ( $params{price_type} eq 'lastcost' ) {
 
 646       return $part->items_lastcost_sum;
 
 648       return $part->items_sellprice_sum;
 
 653 sub check_part_not_modified {
 
 656   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 663   my $is_new = !$self->part->id;
 
 665   my $params = delete($::form->{part}) || { };
 
 667   delete $params->{id};
 
 668   # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
 
 669   delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
 
 670   $self->part->assign_attributes(%{ $params});
 
 671   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 673   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 674   # will be the case for used assortments when saving, or when a used assortment
 
 676   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 677     $self->part->assortment_items([]);
 
 678     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 681   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 682     $self->part->assemblies([]); # completely rewrite assortments each time
 
 683     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 686   $self->part->translations([]);
 
 687   $self->parse_form_translations;
 
 689   $self->part->prices([]);
 
 690   $self->parse_form_prices;
 
 692   $self->parse_form_makemodels;
 
 695 sub parse_form_prices {
 
 697   # only save prices > 0
 
 698   my $prices = delete($::form->{prices}) || [];
 
 699   foreach my $price ( @{$prices} ) {
 
 700     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 701     next unless $sellprice > 0; # skip negative prices as well
 
 702     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 703                                pricegroup_id => $price->{pricegroup_id},
 
 706     $self->part->add_prices($p);
 
 710 sub parse_form_translations {
 
 712   # don't add empty translations
 
 713   my $translations = delete($::form->{translations}) || [];
 
 714   foreach my $translation ( @{$translations} ) {
 
 715     next unless $translation->{translation};
 
 716     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 717     $self->part->add_translations( $translation );
 
 721 sub parse_form_makemodels {
 
 725   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 726     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 729   $self->part->makemodels([]);
 
 732   my $makemodels = delete($::form->{makemodels}) || [];
 
 733   foreach my $makemodel ( @{$makemodels} ) {
 
 734     next unless $makemodel->{make};
 
 736     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 738     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 739                                      id         => $makemodel->{id},
 
 740                                      make       => $makemodel->{make},
 
 741                                      model      => $makemodel->{model} || '',
 
 742                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 743                                      sortorder  => $position,
 
 745     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 746       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 747       # don't change lastupdate
 
 748     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 749       # new makemodel, no lastcost entered, leave lastupdate empty
 
 750     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 751       # lastcost hasn't changed, use original lastupdate
 
 752       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 754       $mm->lastupdate(DateTime->now);
 
 756     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 757     $self->part->add_makemodels($mm);
 
 761 sub build_bin_select {
 
 762   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 763     title_key => 'description',
 
 764     default   => $_[0]->bin->id,
 
 768 # get_set_inits for partpicker
 
 771   if ($::form->{no_paginate}) {
 
 772     $_[0]->models->disable_plugin('paginated');
 
 778 # get_set_inits for part controller
 
 782   # used by edit, save, delete and add
 
 784   if ( $::form->{part}{id} ) {
 
 785     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
 
 787     die "part_type missing" unless $::form->{part}{part_type};
 
 788     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 794   return $self->part->orphaned;
 
 800   SL::Controller::Helper::GetModels->new(
 
 807       partnumber  => t8('Partnumber'),
 
 808       description  => t8('Description'),
 
 810     with_objects => [ qw(unit_obj classification) ],
 
 819 sub init_assortment_items {
 
 820   # this init is used while saving and whenever assortments change dynamically
 
 824   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 825   foreach my $assortment_item ( @{$assortment_items} ) {
 
 826     next unless $assortment_item->{parts_id};
 
 828     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 829     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 830                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 831                                           charge        => $assortment_item->{charge},
 
 832                                           unit          => $assortment_item->{unit} || $part->unit,
 
 833                                           position      => $position,
 
 841 sub init_makemodels {
 
 845   my @makemodel_array = ();
 
 846   my $makemodels = delete($::form->{makemodels}) || [];
 
 848   foreach my $makemodel ( @{$makemodels} ) {
 
 849     next unless $makemodel->{make};
 
 851     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 852                                     id        => $makemodel->{id},
 
 853                                     make      => $makemodel->{make},
 
 854                                     model     => $makemodel->{model} || '',
 
 855                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 856                                     sortorder => $position,
 
 857                                   ) or die "Can't create mm";
 
 858     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 859     push(@makemodel_array, $mm);
 
 861   return \@makemodel_array;
 
 864 sub init_assembly_items {
 
 868   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 869   foreach my $assembly_item ( @{$assembly_items} ) {
 
 870     next unless $assembly_item->{parts_id};
 
 872     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 873     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 874                                    bom         => $assembly_item->{bom},
 
 875                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 876                                    position    => $position,
 
 883 sub init_all_warehouses {
 
 885   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 888 sub init_all_languages {
 
 889   SL::DB::Manager::Language->get_all_sorted;
 
 892 sub init_all_partsgroups {
 
 894   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 897 sub init_all_buchungsgruppen {
 
 899   if ( $self->part->orphaned ) {
 
 900     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 902     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 908   if ( $self->part->orphaned ) {
 
 909     return SL::DB::Manager::Unit->get_all_sorted;
 
 911     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 915 sub init_all_payment_terms {
 
 917   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
 920 sub init_all_price_factors {
 
 921   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 924 sub init_all_pricegroups {
 
 925   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 928 # model used to filter/display the parts in the multi-items dialog
 
 929 sub init_multi_items_models {
 
 930   SL::Controller::Helper::GetModels->new(
 
 933     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
 934     disable_plugin => 'paginated',
 
 935     source         => $::form->{multi_items},
 
 941       partnumber  => t8('Partnumber'),
 
 942       description => t8('Description')}
 
 946 # simple checks to run on $::form before saving
 
 948 sub form_check_part_description_exists {
 
 951   return 1 if $::form->{part}{description};
 
 953   $self->js->flash('error', t8('Part Description missing!'))
 
 954            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 955            ->focus('#part_description');
 
 959 sub form_check_assortment_items_exist {
 
 962   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 963   # skip item check for existing assortments that have been used
 
 964   return 1 if ($self->part->id and !$self->part->orphaned);
 
 966   # new or orphaned parts must have items in $::form->{assortment_items}
 
 967   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
 968     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 969              ->focus('#add_assortment_item_name')
 
 970              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
 976 sub form_check_assortment_items_unique {
 
 979   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 981   my %duplicate_elements;
 
 983   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
 984     $duplicate_elements{$_}++ if $count{$_}++;
 
 987   if ( keys %duplicate_elements ) {
 
 988     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 989              ->flash('error', t8('There are duplicate assortment items'));
 
 995 sub form_check_assembly_items_exist {
 
 998   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1000   # skip item check for existing assembly that have been used
 
1001   return 1 if ($self->part->id and !$self->part->orphaned);
 
1003   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1004     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1005              ->focus('#add_assembly_item_name')
 
1006              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1012 sub form_check_partnumber_is_unique {
 
1015   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1016     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1018       $self->js->flash('error', t8('The partnumber already exists!'))
 
1019                ->focus('#part_description');
 
1026 # general checking functions
 
1027 sub check_next_transnumber_is_free {
 
1030   my ($next_transnumber, $count);
 
1031   $self->part->db->with_transaction(sub {
 
1032     $next_transnumber = $self->part->get_next_trans_number;
 
1033     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
 
1036   $count ? return 0 : return 1;
 
1040   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1046   $self->form_check_part_description_exists || return 0;
 
1047   $self->form_check_assortment_items_exist  || return 0;
 
1048   $self->form_check_assortment_items_unique || return 0;
 
1049   $self->form_check_assembly_items_exist    || return 0;
 
1050   $self->form_check_partnumber_is_unique    || return 0;
 
1055 sub check_has_valid_part_type {
 
1056   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1059 sub render_assortment_items_to_html {
 
1060   my ($self, $assortment_items, $number_of_items) = @_;
 
1062   my $position = $number_of_items + 1;
 
1064   foreach my $ai (@$assortment_items) {
 
1065     $html .= $self->p->render('part/_assortment_row',
 
1066                               PART     => $self->part,
 
1067                               orphaned => $self->orphaned,
 
1069                               listrow  => $position % 2 ? 1 : 0,
 
1070                               position => $position, # for legacy assemblies
 
1077 sub render_assembly_items_to_html {
 
1078   my ($self, $assembly_items, $number_of_items) = @_;
 
1080   my $position = $number_of_items + 1;
 
1082   foreach my $ai (@{$assembly_items}) {
 
1083     $html .= $self->p->render('part/_assembly_row',
 
1084                               PART     => $self->part,
 
1085                               orphaned => $self->orphaned,
 
1087                               listrow  => $position % 2 ? 1 : 0,
 
1088                               position => $position, # for legacy assemblies
 
1095 sub parse_add_items_to_objects {
 
1096   my ($self, %params) = @_;
 
1097   my $part_type = $params{part_type};
 
1098   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1099   my $position = $params{position} || 1;
 
1101   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1104   foreach my $item ( @add_items ) {
 
1105     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1107     if ( $part_type eq 'assortment' ) {
 
1108        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1109                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1110                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1111                                          position      => $position,
 
1112                                         ) or die "Can't create AssortmentItem from item";
 
1113     } elsif ( $part_type eq 'assembly' ) {
 
1114       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1115                                  # id          => $self->assembly->id, # will be set on save
 
1116                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1117                                  bom         => 0, # default when adding: no bom
 
1118                                  position    => $position,
 
1121       die "part_type must be assortment or assembly";
 
1123     push(@item_objects, $ai);
 
1127   return \@item_objects;
 
1130 sub _setup_form_action_bar {
 
1133   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1135   for my $bar ($::request->layout->get('actionbar')) {
 
1140           call      => [ 'kivi.Part.save' ],
 
1141           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1142           accesskey => 'enter',
 
1146           call     => [ 'kivi.Part.use_as_new' ],
 
1147           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1148                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1151       ], # end of combobox "Save"
 
1155         call     => [ 'kivi.Part.delete' ],
 
1156         confirm  => t8('Do you really want to delete this object?'),
 
1157         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1158                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1159                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1167         call     => [ 'kivi.Part.open_history_popup' ],
 
1168         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1169                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1184 SL::Controller::Part - Part CRUD controller
 
1188 Controller for adding/editing/saving/deleting parts.
 
1190 All the relations are loaded at once and saving the part, adding a history
 
1191 entry and saving CVars happens inside one transaction.  When saving the old
 
1192 relations are deleted and written as new to the database.
 
1194 Relations for parts:
 
1202 =item assembly items
 
1204 =item assortment items
 
1212 There are 4 different part types:
 
1218 The "default" part type.
 
1220 inventory_accno_id is set.
 
1224 Services can't be stocked.
 
1226 inventory_accno_id isn't set.
 
1230 Assemblies consist of other parts, services, assemblies or assortments. They
 
1231 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1232 have to make them, which reduces the stock by its respective components. Once
 
1233 an assembly item has been created there is currently no way to "disassemble" it
 
1234 again. An assembly item can appear several times in one assembly. An assmbly is
 
1235 sold as one item with a defined sellprice and lastcost. If the component prices
 
1236 change the assortment price remains the same. The assembly items may be printed
 
1237 in a record if the item's "bom" is set.
 
1241 Similar to assembly, but each assortment item may only appear once per
 
1242 assortment. When selling an assortment the assortment items are added to the
 
1243 record together with the assortment, which is added with sellprice 0.
 
1245 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1246 determined by the sum of the current assortment item prices when the assortment
 
1247 is added to a record. This also means that price rules and customer discounts
 
1248 will be applied to the assortment items.
 
1250 Once the assortment items have been added they may be modified or deleted, just
 
1251 as if they had been added manually, the individual assortment items aren't
 
1252 linked to the assortment or the other assortment items in any way.
 
1260 =item C<action_add_part>
 
1262 =item C<action_add_service>
 
1264 =item C<action_add_assembly>
 
1266 =item C<action_add_assortment>
 
1268 =item C<action_add PART_TYPE>
 
1270 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1271 parameter part_type as an action. Example:
 
1273   controller.pl?action=Part/add&part_type=service
 
1275 =item C<action_save>
 
1277 Saves the current part and then reloads the edit page for the part.
 
1279 =item C<action_use_as_new>
 
1281 Takes the information from the current part, plus any modifications made on the
 
1282 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1283 set empty, so a new partnumber from the number range will be used if the user
 
1284 doesn't enter one manually.
 
1286 Unsaved changes to the original part aren't updated.
 
1288 The part type cannot be changed in this way.
 
1290 =item C<action_delete>
 
1292 Deletes the current part and then redirects to the main page, there is no
 
1295 The delete button only appears if the part is 'orphaned', according to
 
1296 SL::DB::Part orphaned.
 
1298 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1299 the inventory, or is part of an assembly or assortment.
 
1301 If the part is deleted its relations prices, makdemodel, assembly,
 
1302 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1304 Before this controller items that appeared in inventory didn't count as
 
1305 orphaned and could be deleted and the inventory entries were also deleted, this
 
1306 "feature" hasn't been implemented.
 
1308 =item C<action_edit part.id>
 
1310 Load and display a part for editing.
 
1312   controller.pl?action=Part/edit&part.id=12345
 
1314 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1318 =head1 BUTTON ACTIONS
 
1324 Opens a popup displaying all the history entries. Once a new history controller
 
1325 is written the button could link there instead, with the part already selected.
 
1333 =item C<action_update_item_totals>
 
1335 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1336 amount of an item changes. The sum of all sellprices and lastcosts is
 
1337 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1339 =item C<action_add_assortment_item>
 
1341 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1343 If the item already exists in the assortment the item isn't added and a Flash
 
1346 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1347 after adding each new item, add the new object to the item objects that were
 
1348 already parsed, calculate totals via a dummy part then update the row and the
 
1351 =item C<action_add_assembly_item>
 
1353 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1355 If the item already exists in the assembly a flash info is generated, but the
 
1358 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1359 after adding each new item, add the new object to the item objects that were
 
1360 already parsed, calculate totals via a dummy part then update the row and the
 
1363 =item C<action_add_multi_assortment_items>
 
1365 Parses the items to be added from the form generated by the multi input and
 
1366 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1367 assortment items are renumbered and the sums recalculated via
 
1368 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1370 =item C<action_add_multi_assembly_items>
 
1372 Parses the items to be added from the form generated by the multi input and
 
1373 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1374 assembly items are renumbered and the sums recalculated via
 
1375 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1377 =item C<action_show_multi_items_dialog>
 
1379 =item C<action_multi_items_update_result>
 
1381 =item C<action_add_makemodel_row>
 
1383 Add a new makemodel row with the vendor that was selected via the vendor
 
1386 Checks the already existing makemodels and warns if a row with that vendor
 
1387 already exists. Currently it is possible to have duplicate vendor rows.
 
1389 =item C<action_reorder_items>
 
1391 Sorts the item table for assembly or assortment items.
 
1393 =item C<action_warehouse_changed>
 
1397 =head1 ACTIONS part picker
 
1401 =item C<action_ajax_autocomplete>
 
1403 =item C<action_test_page>
 
1405 =item C<action_part_picker_search>
 
1407 =item C<action_part_picker_result>
 
1409 =item C<action_show>
 
1419 Calls some simple checks that test the submitted $::form for obvious errors.
 
1420 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1422 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1423 some cases extra actions are taken, e.g. if the part description is missing the
 
1424 basic data tab is selected and the description input field is focussed.
 
1430 =item C<form_check_part_description_exists>
 
1432 =item C<form_check_assortment_items_exist>
 
1434 =item C<form_check_assortment_items_unique>
 
1436 =item C<form_check_assembly_items_exist>
 
1438 =item C<form_check_partnumber_is_unique>
 
1442 =head1 HELPER FUNCTIONS
 
1448 When submitting the form for saving, parses the transmitted form. Expects the
 
1452  $::form->{makemodels}
 
1453  $::form->{translations}
 
1455  $::form->{assemblies}
 
1456  $::form->{assortments}
 
1458 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1460 =item C<recalc_item_totals %params>
 
1462 Helper function for calculating the total lastcost and sellprice for assemblies
 
1463 or assortments according to their items, which are parsed from the current
 
1466 Is called whenever the qty of an item is changed or items are deleted.
 
1470 * part_type : 'assortment' or 'assembly' (mandatory)
 
1472 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1474 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1476 Doesn't work for recursive items.
 
1480 =head1 GET SET INITS
 
1482 There are get_set_inits for
 
1490 which parse $::form and automatically create an array of objects.
 
1492 These inits are used during saving and each time a new element is added.
 
1496 =item C<init_makemodels>
 
1498 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1499 $self->part->makemodels, ready to be saved.
 
1501 Used for saving parts and adding new makemodel rows.
 
1503 =item C<parse_add_items_to_objects PART_TYPE>
 
1505 Parses the resulting form from either the part-picker submit or the multi-item
 
1506 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1507 can be rendered via C<render_assortment_items_to_html> or
 
1508 C<render_assembly_items_to_html>.
 
1510 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1511 Optional param: position (used for numbering and listrow class)
 
1513 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1515 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1516 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1517 assortment items are added.
 
1519 =item C<parse_form_makemodels>
 
1521 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1522 remembers when the lastcost for that vendor changed the last time.
 
1524 So the original values are cloned and remembered, so we can compare if lastcost
 
1525 was changed in $::form, and keep or update lastupdate.
 
1527 lastcost isn't updated until the first time it was saved with a value, until
 
1530 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1531 makemodel entries exist or not.
 
1533 We still need init_makemodels for when we open the part for editing.
 
1543 It should be possible to jump to the edit page in a specific tab
 
1547 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1548 back to the order again afterwards.
 
1552 Support units when adding assembly items or assortment items. Currently the
 
1553 default unit of the item is always used.
 
1557 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1558 consists of other assemblies.
 
1564 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>