1 package SL::Controller::Part;
 
   4 use parent qw(SL::Controller::Base);
 
   8 use SL::DB::PartsGroup;
 
  10 use SL::Controller::Helper::GetModels;
 
  11 use SL::Locale::String qw(t8);
 
  13 use List::Util qw(sum);
 
  14 use SL::Helper::Flash;
 
  18 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  20 use SL::MoreCommon qw(save_form);
 
  22 use SL::Presenter::EscapedText qw(escape is_escaped);
 
  23 use SL::Presenter::Tag qw(select_tag);
 
  25 use Rose::Object::MakeMethods::Generic (
 
  26   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  27                                   makemodels shops_not_assigned
 
  30                                   assortment assortment_items assembly assembly_items
 
  31                                   all_pricegroups all_translations all_partsgroups all_units
 
  32                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  33                                   parts_classification_filter
 
  34                                   all_languages all_units all_price_factors) ],
 
  35   'scalar'                => [ qw(warehouse bin) ],
 
  39 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  40                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  42 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  44 # actions for editing parts
 
  47   my ($self, %params) = @_;
 
  49   $self->part( SL::DB::Part->new_part );
 
  53 sub action_add_service {
 
  54   my ($self, %params) = @_;
 
  56   $self->part( SL::DB::Part->new_service );
 
  60 sub action_add_assembly {
 
  61   my ($self, %params) = @_;
 
  63   $self->part( SL::DB::Part->new_assembly );
 
  67 sub action_add_assortment {
 
  68   my ($self, %params) = @_;
 
  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     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 201 sub action_use_as_new {
 
 202   my ($self, %params) = @_;
 
 204   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 205   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 207   $self->part($oldpart->clone_and_reset_deep);
 
 209   $self->part->partnumber(undef);
 
 215   my ($self, %params) = @_;
 
 221   my ($self, %params) = @_;
 
 223   $self->_set_javascript;
 
 224   $self->_setup_form_action_bar;
 
 226   my (%assortment_vars, %assembly_vars);
 
 227   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 228   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 230   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 231   $_->{valid}                = 1 for @{ $params{CUSTOM_VARIABLES} };
 
 233   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 234     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 236   my %title_hash = ( part       => t8('Edit Part'),
 
 237                      assembly   => t8('Edit Assembly'),
 
 238                      service    => t8('Edit Service'),
 
 239                      assortment => t8('Edit Assortment'),
 
 242   $self->part->prices([])       unless $self->part->prices;
 
 243   $self->part->translations([]) unless $self->part->translations;
 
 247     title             => $title_hash{$self->part->part_type},
 
 250     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 251     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 252     oldpartnumber     => $::form->{oldpartnumber},
 
 253     old_id            => $::form->{old_id},
 
 261   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 262   $_[0]->render('part/history', { layout => 0 },
 
 263                                   history_entries => $history_entries);
 
 266 sub action_update_item_totals {
 
 269   my $part_type = $::form->{part_type};
 
 270   die unless $part_type =~ /^(assortment|assembly)$/;
 
 272   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 273   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 275   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 278     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 279     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 280     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 281     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 282     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 283     ->no_flash_clear->render();
 
 286 sub action_add_multi_assortment_items {
 
 289   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 290   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 292   $self->js->run('kivi.Part.close_picker_dialogs')
 
 293            ->append('#assortment_rows', $html)
 
 294            ->run('kivi.Part.renumber_positions')
 
 295            ->run('kivi.Part.assortment_recalc')
 
 299 sub action_add_multi_assembly_items {
 
 302   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 304   foreach my $item (@{$item_objects}) {
 
 305     my $errstr = validate_assembly($item->part,$self->part);
 
 306     $self->js->flash('error',$errstr) if     $errstr;
 
 307     push (@checked_objects,$item)     unless $errstr;
 
 310   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 312   $self->js->run('kivi.Part.close_picker_dialogs')
 
 313            ->append('#assembly_rows', $html)
 
 314            ->run('kivi.Part.renumber_positions')
 
 315            ->run('kivi.Part.assembly_recalc')
 
 319 sub action_add_assortment_item {
 
 320   my ($self, %params) = @_;
 
 322   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 324   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 326   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 327   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 328     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 331   my $number_of_items = scalar @{$self->assortment_items};
 
 332   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 333   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 335   push(@{$self->assortment_items}, @{$item_objects});
 
 336   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 337   $part->assortment_items(@{$self->assortment_items});
 
 338   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 339   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 340   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 343     ->append('#assortment_rows'        , $html)  # append in tbody
 
 344     ->val('.add_assortment_item_input' , '')
 
 345     ->run('kivi.Part.focus_last_assortment_input')
 
 346     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 347     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 348     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 349     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 350     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 354 sub action_add_assembly_item {
 
 357   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 359   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 361   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 363   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 364   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 365     $duplicate_warning++;
 
 368   my $number_of_items = scalar @{$self->assembly_items};
 
 369   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 371     foreach my $item (@{$item_objects}) {
 
 372       my $errstr = validate_assembly($item->part,$self->part);
 
 373       return $self->js->flash('error',$errstr)->render if $errstr;
 
 378   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 380   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 382   push(@{$self->assembly_items}, @{$item_objects});
 
 383   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 384   $part->assemblies(@{$self->assembly_items});
 
 385   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 386   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 387   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 390     ->append('#assembly_rows', $html)  # append in tbody
 
 391     ->val('.add_assembly_item_input' , '')
 
 392     ->run('kivi.Part.focus_last_assembly_input')
 
 393     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 394     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 395     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 396     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 397     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 401 sub action_show_multi_items_dialog {
 
 402   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 403     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 407 sub action_multi_items_update_result {
 
 410   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 412   my $count = $_[0]->multi_items_models->count;
 
 415     my $text = escape($::locale->text('No results.'));
 
 416     $_[0]->render($text, { layout => 0 });
 
 417   } elsif ($count > $max_count) {
 
 418     my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 419     $_[0]->render($text, { layout => 0 });
 
 421     my $multi_items = $_[0]->multi_items_models->get;
 
 422     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 423                   multi_items => $multi_items);
 
 427 sub action_add_makemodel_row {
 
 430   my $vendor_id = $::form->{add_makemodel};
 
 432   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 433     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 435   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 436     $self->js->flash('info', t8("This vendor has already been added."));
 
 439   my $position = scalar @{$self->makemodels} + 1;
 
 441   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 445                                   sortorder    => $position,
 
 446                                  ) or die "Can't create MakeModel object";
 
 448   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 450                                      listrow   => $position % 2 ? 0 : 1,
 
 453   # after selection focus on the model field in the row that was just added
 
 455     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 456     ->val('.add_makemodel_input', '')
 
 457     ->run('kivi.Part.focus_last_makemodel_input')
 
 461 sub action_add_customerprice_row {
 
 464   my $customer_id = $::form->{add_customerprice};
 
 466   my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
 
 467     or return $self->js->error(t8("No customer selected or found!"))->render;
 
 469   if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
 
 470     $self->js->flash('info', t8("This customer has already been added."));
 
 473   my $position = scalar @{ $self->customerprices } + 1;
 
 475   my $cu = SL::DB::PartCustomerPrice->new(
 
 476                       customer_id         => $customer->id,
 
 477                       customer_partnumber => '',
 
 479                       sortorder           => $position,
 
 480   ) or die "Can't create Customerprice object";
 
 482   my $row_as_html = $self->p->render(
 
 483                                      'part/_customerprice_row',
 
 484                                       customerprice => $cu,
 
 485                                       listrow       => $position % 2 ? 0
 
 489   $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
 
 490            ->val('.add_customerprice_input', '')
 
 491            ->run('kivi.Part.focus_last_customerprice_input')->render;
 
 494 sub action_reorder_items {
 
 497   my $part_type = $::form->{part_type};
 
 500     partnumber  => sub { $_[0]->part->partnumber },
 
 501     description => sub { $_[0]->part->description },
 
 502     qty         => sub { $_[0]->qty },
 
 503     sellprice   => sub { $_[0]->part->sellprice },
 
 504     lastcost    => sub { $_[0]->part->lastcost },
 
 505     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 508   my $method = $sort_keys{$::form->{order_by}};
 
 511   if ($part_type eq 'assortment') {
 
 512     @items = @{ $self->assortment_items };
 
 514     @items = @{ $self->assembly_items };
 
 517   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 518   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 519     if ($::form->{sort_dir}) {
 
 520       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 522       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 525     if ($::form->{sort_dir}) {
 
 526       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 528       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 532   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 535 sub action_warehouse_changed {
 
 538   if ($::form->{warehouse_id} ) {
 
 539     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 540     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 542     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 543       $self->bin($self->warehouse->bins->[0]);
 
 545         ->html('#bin', $self->build_bin_select)
 
 546         ->focus('#part_bin_id');
 
 547       return $self->js->render;
 
 551   # no warehouse was selected, empty the bin field and reset the id
 
 553        ->val('#part_bin_id', undef)
 
 556   return $self->js->render;
 
 559 sub action_ajax_autocomplete {
 
 560   my ($self, %params) = @_;
 
 562   # if someone types something, and hits enter, assume he entered the full name.
 
 563   # if something matches, treat that as sole match
 
 564   # since we need a second get models instance with different filters for that,
 
 565   # we only modify the original filter temporarily in place
 
 566   if ($::form->{prefer_exact}) {
 
 567     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 569     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 572       paginated    => { per_page => 2 },
 
 573       with_objects => [ qw(unit_obj classification) ],
 
 576     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 577       $self->parts($exact_matches);
 
 583      value       => $_->displayable_name,
 
 584      label       => $_->displayable_name,
 
 586      partnumber  => $_->partnumber,
 
 587      description => $_->description,
 
 589      part_type   => $_->part_type,
 
 591      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 593   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 595   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 598 sub action_test_page {
 
 599   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 602 sub action_part_picker_search {
 
 603   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 606 sub action_part_picker_result {
 
 607   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 613   if ($::request->type eq 'json') {
 
 618       $part_hash          = $self->part->as_tree;
 
 619       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 622     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 627 sub validate_add_items {
 
 628   scalar @{$::form->{add_items}};
 
 631 sub prepare_assortment_render_vars {
 
 634   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 635                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 636                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 638   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 643 sub prepare_assembly_render_vars {
 
 646   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 647                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 648                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 650   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 658   check_has_valid_part_type($self->part->part_type);
 
 660   $self->_set_javascript;
 
 661   $self->_setup_form_action_bar;
 
 663   my %title_hash = ( part       => t8('Add Part'),
 
 664                      assembly   => t8('Add Assembly'),
 
 665                      service    => t8('Add Service'),
 
 666                      assortment => t8('Add Assortment'),
 
 671     title => $title_hash{$self->part->part_type},
 
 676 sub _set_javascript {
 
 678   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
 
 679   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 682 sub recalc_item_totals {
 
 683   my ($self, %params) = @_;
 
 685   if ( $params{part_type} eq 'assortment' ) {
 
 686     return 0 unless scalar @{$self->assortment_items};
 
 687   } elsif ( $params{part_type} eq 'assembly' ) {
 
 688     return 0 unless scalar @{$self->assembly_items};
 
 690     carp "can only calculate sum for assortments and assemblies";
 
 693   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 694   if ( $part->is_assortment ) {
 
 695     $part->assortment_items( @{$self->assortment_items} );
 
 696     if ( $params{price_type} eq 'lastcost' ) {
 
 697       return $part->items_lastcost_sum;
 
 699       if ( $params{pricegroup_id} ) {
 
 700         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 702         return $part->items_sellprice_sum;
 
 705   } elsif ( $part->is_assembly ) {
 
 706     $part->assemblies( @{$self->assembly_items} );
 
 707     if ( $params{price_type} eq 'lastcost' ) {
 
 708       return $part->items_lastcost_sum;
 
 710       return $part->items_sellprice_sum;
 
 715 sub check_part_not_modified {
 
 718   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 725   my $is_new = !$self->part->id;
 
 727   my $params = delete($::form->{part}) || { };
 
 729   delete $params->{id};
 
 730   $self->part->assign_attributes(%{ $params});
 
 731   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 733   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 734   # will be the case for used assortments when saving, or when a used assortment
 
 736   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 737     $self->part->assortment_items([]);
 
 738     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 741   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 742     $self->part->assemblies([]); # completely rewrite assortments each time
 
 743     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 746   $self->part->translations([]);
 
 747   $self->parse_form_translations;
 
 749   $self->part->prices([]);
 
 750   $self->parse_form_prices;
 
 752   $self->parse_form_customerprices;
 
 753   $self->parse_form_makemodels;
 
 756 sub parse_form_prices {
 
 758   # only save prices > 0
 
 759   my $prices = delete($::form->{prices}) || [];
 
 760   foreach my $price ( @{$prices} ) {
 
 761     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 762     next unless $sellprice > 0; # skip negative prices as well
 
 763     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 764                                pricegroup_id => $price->{pricegroup_id},
 
 767     $self->part->add_prices($p);
 
 771 sub parse_form_translations {
 
 773   # don't add empty translations
 
 774   my $translations = delete($::form->{translations}) || [];
 
 775   foreach my $translation ( @{$translations} ) {
 
 776     next unless $translation->{translation};
 
 777     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 778     $self->part->add_translations( $translation );
 
 782 sub parse_form_makemodels {
 
 786   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 787     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 790   $self->part->makemodels([]);
 
 793   my $makemodels = delete($::form->{makemodels}) || [];
 
 794   foreach my $makemodel ( @{$makemodels} ) {
 
 795     next unless $makemodel->{make};
 
 797     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 799     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 800                                      id         => $makemodel->{id},
 
 801                                      make       => $makemodel->{make},
 
 802                                      model      => $makemodel->{model} || '',
 
 803                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 804                                      sortorder  => $position,
 
 806     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 807       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 808       # don't change lastupdate
 
 809     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 810       # new makemodel, no lastcost entered, leave lastupdate empty
 
 811     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 812       # lastcost hasn't changed, use original lastupdate
 
 813       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 815       $mm->lastupdate(DateTime->now);
 
 817     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 818     $self->part->add_makemodels($mm);
 
 822 sub parse_form_customerprices {
 
 825   my $customerprices_map;
 
 826   if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
 
 827     $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
 
 830   $self->part->customerprices([]);
 
 833   my $customerprices = delete($::form->{customerprices}) || [];
 
 834   foreach my $customerprice ( @{$customerprices} ) {
 
 835     next unless $customerprice->{customer_id};
 
 837     my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
 
 839     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 840                                      id                   => $customerprice->{id},
 
 841                                      customer_id          => $customerprice->{customer_id},
 
 842                                      customer_partnumber  => $customerprice->{customer_partnumber} || '',
 
 843                                      price                => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
 
 844                                      sortorder            => $position,
 
 846     if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
 
 847       # lastupdate isn't set, original price is 0 and new lastcost is 0
 
 848       # don't change lastupdate
 
 849     } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
 
 850       # new customerprice, no lastcost entered, leave lastupdate empty
 
 851     } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
 
 852       # price hasn't changed, use original lastupdate
 
 853       $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
 
 855       $cu->lastupdate(DateTime->now);
 
 857     $self->part->add_customerprices($cu);
 
 861 sub build_bin_select {
 
 862   select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 863     title_key => 'description',
 
 864     default   => $_[0]->bin->id,
 
 869 # get_set_inits for partpicker
 
 872   if ($::form->{no_paginate}) {
 
 873     $_[0]->models->disable_plugin('paginated');
 
 879 # get_set_inits for part controller
 
 883   # used by edit, save, delete and add
 
 885   if ( $::form->{part}{id} ) {
 
 886     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 888     die "part_type missing" unless $::form->{part}{part_type};
 
 889     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 895   return $self->part->orphaned;
 
 901   SL::Controller::Helper::GetModels->new(
 
 908       partnumber  => t8('Partnumber'),
 
 909       description  => t8('Description'),
 
 911     with_objects => [ qw(unit_obj classification) ],
 
 920 sub init_assortment_items {
 
 921   # this init is used while saving and whenever assortments change dynamically
 
 925   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 926   foreach my $assortment_item ( @{$assortment_items} ) {
 
 927     next unless $assortment_item->{parts_id};
 
 929     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 930     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 931                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 932                                           charge        => $assortment_item->{charge},
 
 933                                           unit          => $assortment_item->{unit} || $part->unit,
 
 934                                           position      => $position,
 
 942 sub init_makemodels {
 
 946   my @makemodel_array = ();
 
 947   my $makemodels = delete($::form->{makemodels}) || [];
 
 949   foreach my $makemodel ( @{$makemodels} ) {
 
 950     next unless $makemodel->{make};
 
 952     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 953                                     id        => $makemodel->{id},
 
 954                                     make      => $makemodel->{make},
 
 955                                     model     => $makemodel->{model} || '',
 
 956                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 957                                     sortorder => $position,
 
 958                                   ) or die "Can't create mm";
 
 959     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 960     push(@makemodel_array, $mm);
 
 962   return \@makemodel_array;
 
 965 sub init_customerprices {
 
 969   my @customerprice_array = ();
 
 970   my $customerprices = delete($::form->{customerprices}) || [];
 
 972   foreach my $customerprice ( @{$customerprices} ) {
 
 973     next unless $customerprice->{customer_id};
 
 975     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 976                                     id                  => $customerprice->{id},
 
 977                                     customer_partnumber => $customerprice->{customer_partnumber},
 
 978                                     customer_id         => $customerprice->{customer_id} || '',
 
 979                                     price               => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
 
 980                                     sortorder           => $position,
 
 981                                   ) or die "Can't create cu";
 
 982     # $cu->id($customerprice->{id}) if $customerprice->{id};
 
 983     push(@customerprice_array, $cu);
 
 985   return \@customerprice_array;
 
 988 sub init_assembly_items {
 
 992   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 993   foreach my $assembly_item ( @{$assembly_items} ) {
 
 994     next unless $assembly_item->{parts_id};
 
 996     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 997     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 998                                    bom         => $assembly_item->{bom},
 
 999                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
1000                                    position    => $position,
 
1007 sub init_all_warehouses {
 
1009   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
1012 sub init_all_languages {
 
1013   SL::DB::Manager::Language->get_all_sorted;
 
1016 sub init_all_partsgroups {
 
1018   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
1021 sub init_all_buchungsgruppen {
 
1023   if ( $self->part->orphaned ) {
 
1024     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
1026     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
1030 sub init_shops_not_assigned {
 
1033   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
1034   if ( @used_shop_ids ) {
 
1035     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
1038     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
1042 sub init_all_units {
 
1044   if ( $self->part->orphaned ) {
 
1045     return SL::DB::Manager::Unit->get_all_sorted;
 
1047     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
1051 sub init_all_payment_terms {
 
1053   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
1056 sub init_all_price_factors {
 
1057   SL::DB::Manager::PriceFactor->get_all_sorted;
 
1060 sub init_all_pricegroups {
 
1061   SL::DB::Manager::Pricegroup->get_all_sorted;
 
1064 # model used to filter/display the parts in the multi-items dialog
 
1065 sub init_multi_items_models {
 
1066   SL::Controller::Helper::GetModels->new(
 
1067     controller     => $_[0],
 
1069     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
1070     disable_plugin => 'paginated',
 
1071     source         => $::form->{multi_items},
 
1077       partnumber  => t8('Partnumber'),
 
1078       description => t8('Description')}
 
1082 sub init_parts_classification_filter {
 
1083   return [] unless $::form->{parts_classification_type};
 
1085   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
1086   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
1088   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
1091 # simple checks to run on $::form before saving
 
1093 sub form_check_part_description_exists {
 
1096   return 1 if $::form->{part}{description};
 
1098   $self->js->flash('error', t8('Part Description missing!'))
 
1099            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
1100            ->focus('#part_description');
 
1104 sub form_check_assortment_items_exist {
 
1107   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1108   # skip item check for existing assortments that have been used
 
1109   return 1 if ($self->part->id and !$self->part->orphaned);
 
1111   # new or orphaned parts must have items in $::form->{assortment_items}
 
1112   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1113     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1114              ->focus('#add_assortment_item_name')
 
1115              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1121 sub form_check_assortment_items_unique {
 
1124   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1126   my %duplicate_elements;
 
1128   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1129     $duplicate_elements{$_}++ if $count{$_}++;
 
1132   if ( keys %duplicate_elements ) {
 
1133     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1134              ->flash('error', t8('There are duplicate assortment items'));
 
1140 sub form_check_assembly_items_exist {
 
1143   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1145   # skip item check for existing assembly that have been used
 
1146   return 1 if ($self->part->id and !$self->part->orphaned);
 
1148   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1149     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1150              ->focus('#add_assembly_item_name')
 
1151              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1157 sub form_check_partnumber_is_unique {
 
1160   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1161     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1163       $self->js->flash('error', t8('The partnumber already exists!'))
 
1164                ->focus('#part_description');
 
1171 # general checking functions
 
1174   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1180   $self->form_check_part_description_exists || return 0;
 
1181   $self->form_check_assortment_items_exist  || return 0;
 
1182   $self->form_check_assortment_items_unique || return 0;
 
1183   $self->form_check_assembly_items_exist    || return 0;
 
1184   $self->form_check_partnumber_is_unique    || return 0;
 
1189 sub check_has_valid_part_type {
 
1190   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1193 sub render_assortment_items_to_html {
 
1194   my ($self, $assortment_items, $number_of_items) = @_;
 
1196   my $position = $number_of_items + 1;
 
1198   foreach my $ai (@$assortment_items) {
 
1199     $html .= $self->p->render('part/_assortment_row',
 
1200                               PART     => $self->part,
 
1201                               orphaned => $self->orphaned,
 
1203                               listrow  => $position % 2 ? 1 : 0,
 
1204                               position => $position, # for legacy assemblies
 
1211 sub render_assembly_items_to_html {
 
1212   my ($self, $assembly_items, $number_of_items) = @_;
 
1214   my $position = $number_of_items + 1;
 
1216   foreach my $ai (@{$assembly_items}) {
 
1217     $html .= $self->p->render('part/_assembly_row',
 
1218                               PART     => $self->part,
 
1219                               orphaned => $self->orphaned,
 
1221                               listrow  => $position % 2 ? 1 : 0,
 
1222                               position => $position, # for legacy assemblies
 
1229 sub parse_add_items_to_objects {
 
1230   my ($self, %params) = @_;
 
1231   my $part_type = $params{part_type};
 
1232   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1233   my $position = $params{position} || 1;
 
1235   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1238   foreach my $item ( @add_items ) {
 
1239     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1241     if ( $part_type eq 'assortment' ) {
 
1242        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1243                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1244                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1245                                          position      => $position,
 
1246                                         ) or die "Can't create AssortmentItem from item";
 
1247     } elsif ( $part_type eq 'assembly' ) {
 
1248       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1249                                  # id          => $self->assembly->id, # will be set on save
 
1250                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1251                                  bom         => 0, # default when adding: no bom
 
1252                                  position    => $position,
 
1255       die "part_type must be assortment or assembly";
 
1257     push(@item_objects, $ai);
 
1261   return \@item_objects;
 
1264 sub _setup_form_action_bar {
 
1267   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1269   for my $bar ($::request->layout->get('actionbar')) {
 
1274           call      => [ 'kivi.Part.save' ],
 
1275           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1279           call     => [ 'kivi.Part.use_as_new' ],
 
1280           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1281                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1284       ], # end of combobox "Save"
 
1288         call     => [ 'kivi.Part.delete' ],
 
1289         confirm  => t8('Do you really want to delete this object?'),
 
1290         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1291                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1292                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1300         call     => [ 'kivi.Part.open_history_popup' ],
 
1301         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1302                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1317 SL::Controller::Part - Part CRUD controller
 
1321 Controller for adding/editing/saving/deleting parts.
 
1323 All the relations are loaded at once and saving the part, adding a history
 
1324 entry and saving CVars happens inside one transaction.  When saving the old
 
1325 relations are deleted and written as new to the database.
 
1327 Relations for parts:
 
1335 =item assembly items
 
1337 =item assortment items
 
1345 There are 4 different part types:
 
1351 The "default" part type.
 
1353 inventory_accno_id is set.
 
1357 Services can't be stocked.
 
1359 inventory_accno_id isn't set.
 
1363 Assemblies consist of other parts, services, assemblies or assortments. They
 
1364 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1365 have to make them, which reduces the stock by its respective components. Once
 
1366 an assembly item has been created there is currently no way to "disassemble" it
 
1367 again. An assembly item can appear several times in one assembly. An assmbly is
 
1368 sold as one item with a defined sellprice and lastcost. If the component prices
 
1369 change the assortment price remains the same. The assembly items may be printed
 
1370 in a record if the item's "bom" is set.
 
1374 Similar to assembly, but each assortment item may only appear once per
 
1375 assortment. When selling an assortment the assortment items are added to the
 
1376 record together with the assortment, which is added with sellprice 0.
 
1378 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1379 determined by the sum of the current assortment item prices when the assortment
 
1380 is added to a record. This also means that price rules and customer discounts
 
1381 will be applied to the assortment items.
 
1383 Once the assortment items have been added they may be modified or deleted, just
 
1384 as if they had been added manually, the individual assortment items aren't
 
1385 linked to the assortment or the other assortment items in any way.
 
1393 =item C<action_add_part>
 
1395 =item C<action_add_service>
 
1397 =item C<action_add_assembly>
 
1399 =item C<action_add_assortment>
 
1401 =item C<action_add PART_TYPE>
 
1403 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1404 parameter part_type as an action. Example:
 
1406   controller.pl?action=Part/add&part_type=service
 
1408 =item C<action_add_from_record>
 
1410 When adding new items to records they can be created on the fly if the entered
 
1411 partnumber or description doesn't exist yet. After being asked what part type
 
1412 the new item should have the user is redirected to the correct edit page.
 
1414 Depending on whether the item was added from a sales or a purchase record, only
 
1415 the relevant part classifications should be selectable for new item, so this
 
1416 parameter is passed on via a hidden parts_classification_type in the new_item
 
1419 =item C<action_save>
 
1421 Saves the current part and then reloads the edit page for the part.
 
1423 =item C<action_use_as_new>
 
1425 Takes the information from the current part, plus any modifications made on the
 
1426 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1427 set empty, so a new partnumber from the number range will be used if the user
 
1428 doesn't enter one manually.
 
1430 Unsaved changes to the original part aren't updated.
 
1432 The part type cannot be changed in this way.
 
1434 =item C<action_delete>
 
1436 Deletes the current part and then redirects to the main page, there is no
 
1439 The delete button only appears if the part is 'orphaned', according to
 
1440 SL::DB::Part orphaned.
 
1442 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1443 the inventory, or is part of an assembly or assortment.
 
1445 If the part is deleted its relations prices, makdemodel, assembly,
 
1446 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1448 Before this controller items that appeared in inventory didn't count as
 
1449 orphaned and could be deleted and the inventory entries were also deleted, this
 
1450 "feature" hasn't been implemented.
 
1452 =item C<action_edit part.id>
 
1454 Load and display a part for editing.
 
1456   controller.pl?action=Part/edit&part.id=12345
 
1458 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1462 =head1 BUTTON ACTIONS
 
1468 Opens a popup displaying all the history entries. Once a new history controller
 
1469 is written the button could link there instead, with the part already selected.
 
1477 =item C<action_update_item_totals>
 
1479 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1480 amount of an item changes. The sum of all sellprices and lastcosts is
 
1481 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1483 =item C<action_add_assortment_item>
 
1485 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1487 If the item already exists in the assortment the item isn't added and a Flash
 
1490 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1491 after adding each new item, add the new object to the item objects that were
 
1492 already parsed, calculate totals via a dummy part then update the row and the
 
1495 =item C<action_add_assembly_item>
 
1497 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1499 If the item already exists in the assembly a flash info is generated, but the
 
1502 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1503 after adding each new item, add the new object to the item objects that were
 
1504 already parsed, calculate totals via a dummy part then update the row and the
 
1507 =item C<action_add_multi_assortment_items>
 
1509 Parses the items to be added from the form generated by the multi input and
 
1510 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1511 assortment items are renumbered and the sums recalculated via
 
1512 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1514 =item C<action_add_multi_assembly_items>
 
1516 Parses the items to be added from the form generated by the multi input and
 
1517 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1518 assembly items are renumbered and the sums recalculated via
 
1519 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1521 =item C<action_show_multi_items_dialog>
 
1523 =item C<action_multi_items_update_result>
 
1525 =item C<action_add_makemodel_row>
 
1527 Add a new makemodel row with the vendor that was selected via the vendor
 
1530 Checks the already existing makemodels and warns if a row with that vendor
 
1531 already exists. Currently it is possible to have duplicate vendor rows.
 
1533 =item C<action_reorder_items>
 
1535 Sorts the item table for assembly or assortment items.
 
1537 =item C<action_warehouse_changed>
 
1541 =head1 ACTIONS part picker
 
1545 =item C<action_ajax_autocomplete>
 
1547 =item C<action_test_page>
 
1549 =item C<action_part_picker_search>
 
1551 =item C<action_part_picker_result>
 
1553 =item C<action_show>
 
1563 Calls some simple checks that test the submitted $::form for obvious errors.
 
1564 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1566 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1567 some cases extra actions are taken, e.g. if the part description is missing the
 
1568 basic data tab is selected and the description input field is focussed.
 
1574 =item C<form_check_part_description_exists>
 
1576 =item C<form_check_assortment_items_exist>
 
1578 =item C<form_check_assortment_items_unique>
 
1580 =item C<form_check_assembly_items_exist>
 
1582 =item C<form_check_partnumber_is_unique>
 
1586 =head1 HELPER FUNCTIONS
 
1592 When submitting the form for saving, parses the transmitted form. Expects the
 
1596  $::form->{makemodels}
 
1597  $::form->{translations}
 
1599  $::form->{assemblies}
 
1600  $::form->{assortments}
 
1602 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1604 =item C<recalc_item_totals %params>
 
1606 Helper function for calculating the total lastcost and sellprice for assemblies
 
1607 or assortments according to their items, which are parsed from the current
 
1610 Is called whenever the qty of an item is changed or items are deleted.
 
1614 * part_type : 'assortment' or 'assembly' (mandatory)
 
1616 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1618 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1620 Doesn't work for recursive items.
 
1624 =head1 GET SET INITS
 
1626 There are get_set_inits for
 
1634 which parse $::form and automatically create an array of objects.
 
1636 These inits are used during saving and each time a new element is added.
 
1640 =item C<init_makemodels>
 
1642 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1643 $self->part->makemodels, ready to be saved.
 
1645 Used for saving parts and adding new makemodel rows.
 
1647 =item C<parse_add_items_to_objects PART_TYPE>
 
1649 Parses the resulting form from either the part-picker submit or the multi-item
 
1650 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1651 can be rendered via C<render_assortment_items_to_html> or
 
1652 C<render_assembly_items_to_html>.
 
1654 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1655 Optional param: position (used for numbering and listrow class)
 
1657 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1659 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1660 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1661 assortment items are added.
 
1663 =item C<parse_form_makemodels>
 
1665 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1666 remembers when the lastcost for that vendor changed the last time.
 
1668 So the original values are cloned and remembered, so we can compare if lastcost
 
1669 was changed in $::form, and keep or update lastupdate.
 
1671 lastcost isn't updated until the first time it was saved with a value, until
 
1674 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1675 makemodel entries exist or not.
 
1677 We still need init_makemodels for when we open the part for editing.
 
1687 It should be possible to jump to the edit page in a specific tab
 
1691 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1692 back to the order again afterwards.
 
1696 Support units when adding assembly items or assortment items. Currently the
 
1697 default unit of the item is always used.
 
1701 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1702 consists of other assemblies.
 
1708 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>