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);
 
  23 use Rose::Object::MakeMethods::Generic (
 
  24   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  25                                   makemodels shops_not_assigned
 
  27                                   assortment assortment_items assembly assembly_items
 
  28                                   all_pricegroups all_translations all_partsgroups all_units
 
  29                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  30                                   parts_classification_filter
 
  31                                   all_languages all_units all_price_factors) ],
 
  32   'scalar'                => [ qw(warehouse bin) ],
 
  36 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  37                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  39 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  41 # actions for editing parts
 
  44   my ($self, %params) = @_;
 
  46   $self->part( SL::DB::Part->new_part );
 
  50 sub action_add_service {
 
  51   my ($self, %params) = @_;
 
  53   $self->part( SL::DB::Part->new_service );
 
  57 sub action_add_assembly {
 
  58   my ($self, %params) = @_;
 
  60   $self->part( SL::DB::Part->new_assembly );
 
  64 sub action_add_assortment {
 
  65   my ($self, %params) = @_;
 
  67   $self->part( SL::DB::Part->new_assortment );
 
  71 sub action_add_from_record {
 
  74   check_has_valid_part_type($::form->{part}{part_type});
 
  76   die 'parts_classification_type must be "sales" or "purchases"'
 
  77     unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
 
  86   check_has_valid_part_type($::form->{part_type});
 
  88   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  89   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  90   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  91   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  95   my ($self, %params) = @_;
 
  97   # checks that depend only on submitted $::form
 
  98   $self->check_form or return $self->js->render;
 
 100   my $is_new = !$self->part->id; # $ part gets loaded here
 
 102   # check that the part hasn't been modified
 
 104     $self->check_part_not_modified or
 
 105       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;
 
 109        && $::form->{part}{partnumber}
 
 110        && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
 
 112     return $self->js->error(t8('The partnumber is already being used'))->render;
 
 117   my @errors = $self->part->validate;
 
 118   return $self->js->error(@errors)->render if @errors;
 
 120   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 121   $self->part->db->with_transaction(sub {
 
 123     if ( $params{save_as_new} ) {
 
 124       $self->part( $self->part->clone_and_reset_deep );
 
 125       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 128     $self->part->save(cascade => 1);
 
 130     SL::DB::History->new(
 
 131       trans_id    => $self->part->id,
 
 132       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 133       employee_id => SL::DB::Manager::Employee->current->id,
 
 138     CVar->save_custom_variables(
 
 139         dbh          => $self->part->db->dbh,
 
 141         trans_id     => $self->part->id,
 
 142         variables    => $::form, # $::form->{cvar} would be nicer
 
 147   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 150   flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
 
 152   if ( $::form->{callback} ) {
 
 153     $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
 
 156     # default behaviour after save: reload item, this also resets last_modification!
 
 157     $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 161 sub action_save_as_new {
 
 163   $self->action_save(save_as_new=>1);
 
 169   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 171   my $partnumber = $self->part->partnumber; # remember for history log
 
 176       # delete part, together with relationships that don't already
 
 177       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 178       $self->part->delete(cascade => 1);
 
 180       SL::DB::History->new(
 
 181         trans_id    => $self->part->id,
 
 182         snumbers    => 'partnumber_' . $partnumber,
 
 183         employee_id => SL::DB::Manager::Employee->current->id,
 
 185         addition    => 'DELETED',
 
 188   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 190   flash_later('info', t8('The item has been deleted.'));
 
 191   if ( $::form->{callback} ) {
 
 192     $self->redirect_to($::form->unescape($::form->{callback}));
 
 194     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 198 sub action_use_as_new {
 
 199   my ($self, %params) = @_;
 
 201   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 202   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 204   $self->part($oldpart->clone_and_reset_deep);
 
 206   $self->part->partnumber(undef);
 
 212   my ($self, %params) = @_;
 
 218   my ($self, %params) = @_;
 
 220   $self->_set_javascript;
 
 221   $self->_setup_form_action_bar;
 
 223   my (%assortment_vars, %assembly_vars);
 
 224   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 225   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 227   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 229   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 230     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 232   my %title_hash = ( part       => t8('Edit Part'),
 
 233                      assembly   => t8('Edit Assembly'),
 
 234                      service    => t8('Edit Service'),
 
 235                      assortment => t8('Edit Assortment'),
 
 238   $self->part->prices([])       unless $self->part->prices;
 
 239   $self->part->translations([]) unless $self->part->translations;
 
 243     title             => $title_hash{$self->part->part_type},
 
 246     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 247     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 248     oldpartnumber     => $::form->{oldpartnumber},
 
 249     old_id            => $::form->{old_id},
 
 257   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 258   $_[0]->render('part/history', { layout => 0 },
 
 259                                   history_entries => $history_entries);
 
 262 sub action_update_item_totals {
 
 265   my $part_type = $::form->{part_type};
 
 266   die unless $part_type =~ /^(assortment|assembly)$/;
 
 268   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 269   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 271   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 274     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 275     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 276     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 277     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 278     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 279     ->no_flash_clear->render();
 
 282 sub action_add_multi_assortment_items {
 
 285   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 286   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 288   $self->js->run('kivi.Part.close_picker_dialogs')
 
 289            ->append('#assortment_rows', $html)
 
 290            ->run('kivi.Part.renumber_positions')
 
 291            ->run('kivi.Part.assortment_recalc')
 
 295 sub action_add_multi_assembly_items {
 
 298   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 300   foreach my $item (@{$item_objects}) {
 
 301     my $errstr = validate_assembly($item->part,$self->part);
 
 302     $self->js->flash('error',$errstr) if     $errstr;
 
 303     push (@checked_objects,$item)     unless $errstr;
 
 306   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 308   $self->js->run('kivi.Part.close_picker_dialogs')
 
 309            ->append('#assembly_rows', $html)
 
 310            ->run('kivi.Part.renumber_positions')
 
 311            ->run('kivi.Part.assembly_recalc')
 
 315 sub action_add_assortment_item {
 
 316   my ($self, %params) = @_;
 
 318   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 320   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 322   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 323   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 324     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 327   my $number_of_items = scalar @{$self->assortment_items};
 
 328   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 329   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 331   push(@{$self->assortment_items}, @{$item_objects});
 
 332   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 333   $part->assortment_items(@{$self->assortment_items});
 
 334   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 335   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 336   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 339     ->append('#assortment_rows'        , $html)  # append in tbody
 
 340     ->val('.add_assortment_item_input' , '')
 
 341     ->run('kivi.Part.focus_last_assortment_input')
 
 342     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 343     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 344     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 345     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 346     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 350 sub action_add_assembly_item {
 
 353   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 355   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 357   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 359   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 360   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 361     $duplicate_warning++;
 
 364   my $number_of_items = scalar @{$self->assembly_items};
 
 365   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 367     foreach my $item (@{$item_objects}) {
 
 368       my $errstr = validate_assembly($item->part,$self->part);
 
 369       return $self->js->flash('error',$errstr)->render if $errstr;
 
 374   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 376   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 378   push(@{$self->assembly_items}, @{$item_objects});
 
 379   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 380   $part->assemblies(@{$self->assembly_items});
 
 381   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 382   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 383   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 386     ->append('#assembly_rows', $html)  # append in tbody
 
 387     ->val('.add_assembly_item_input' , '')
 
 388     ->run('kivi.Part.focus_last_assembly_input')
 
 389     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 390     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 391     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 392     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 393     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 397 sub action_show_multi_items_dialog {
 
 398   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 399     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 403 sub action_multi_items_update_result {
 
 406   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 408   my $count = $_[0]->multi_items_models->count;
 
 411     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 412     $_[0]->render($text, { layout => 0 });
 
 413   } elsif ($count > $max_count) {
 
 414     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 415     $_[0]->render($text, { layout => 0 });
 
 417     my $multi_items = $_[0]->multi_items_models->get;
 
 418     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 419                   multi_items => $multi_items);
 
 423 sub action_add_makemodel_row {
 
 426   my $vendor_id = $::form->{add_makemodel};
 
 428   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 429     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 431   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 432     $self->js->flash('info', t8("This vendor has already been added."));
 
 435   my $position = scalar @{$self->makemodels} + 1;
 
 437   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 441                                   sortorder    => $position,
 
 442                                  ) or die "Can't create MakeModel object";
 
 444   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 446                                      listrow   => $position % 2 ? 0 : 1,
 
 449   # after selection focus on the model field in the row that was just added
 
 451     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 452     ->val('.add_makemodel_input', '')
 
 453     ->run('kivi.Part.focus_last_makemodel_input')
 
 457 sub action_reorder_items {
 
 460   my $part_type = $::form->{part_type};
 
 463     partnumber  => sub { $_[0]->part->partnumber },
 
 464     description => sub { $_[0]->part->description },
 
 465     qty         => sub { $_[0]->qty },
 
 466     sellprice   => sub { $_[0]->part->sellprice },
 
 467     lastcost    => sub { $_[0]->part->lastcost },
 
 468     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 471   my $method = $sort_keys{$::form->{order_by}};
 
 474   if ($part_type eq 'assortment') {
 
 475     @items = @{ $self->assortment_items };
 
 477     @items = @{ $self->assembly_items };
 
 480   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 481   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 482     if ($::form->{sort_dir}) {
 
 483       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 485       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 488     if ($::form->{sort_dir}) {
 
 489       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 491       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 495   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 498 sub action_warehouse_changed {
 
 501   if ($::form->{warehouse_id} ) {
 
 502     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 503     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 505     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 506       $self->bin($self->warehouse->bins->[0]);
 
 508         ->html('#bin', $self->build_bin_select)
 
 509         ->focus('#part_bin_id');
 
 510       return $self->js->render;
 
 514   # no warehouse was selected, empty the bin field and reset the id
 
 516        ->val('#part_bin_id', undef)
 
 519   return $self->js->render;
 
 522 sub action_ajax_autocomplete {
 
 523   my ($self, %params) = @_;
 
 525   # if someone types something, and hits enter, assume he entered the full name.
 
 526   # if something matches, treat that as sole match
 
 527   # since we need a second get models instance with different filters for that,
 
 528   # we only modify the original filter temporarily in place
 
 529   if ($::form->{prefer_exact}) {
 
 530     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 532     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 535       paginated    => { per_page => 2 },
 
 536       with_objects => [ qw(unit_obj classification) ],
 
 539     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 540       $self->parts($exact_matches);
 
 546      value       => $_->displayable_name,
 
 547      label       => $_->displayable_name,
 
 549      partnumber  => $_->partnumber,
 
 550      description => $_->description,
 
 551      part_type   => $_->part_type,
 
 553      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 555   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 557   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 560 sub action_test_page {
 
 561   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 564 sub action_part_picker_search {
 
 565   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 568 sub action_part_picker_result {
 
 569   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 575   if ($::request->type eq 'json') {
 
 580       $part_hash          = $self->part->as_tree;
 
 581       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 584     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 589 sub validate_add_items {
 
 590   scalar @{$::form->{add_items}};
 
 593 sub prepare_assortment_render_vars {
 
 596   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 597                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 598                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 600   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 605 sub prepare_assembly_render_vars {
 
 608   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 609                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 610                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 612   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 620   check_has_valid_part_type($self->part->part_type);
 
 622   $self->_set_javascript;
 
 623   $self->_setup_form_action_bar;
 
 625   my %title_hash = ( part       => t8('Add Part'),
 
 626                      assembly   => t8('Add Assembly'),
 
 627                      service    => t8('Add Service'),
 
 628                      assortment => t8('Add Assortment'),
 
 633     title => $title_hash{$self->part->part_type},
 
 638 sub _set_javascript {
 
 640   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
 
 641   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 644 sub recalc_item_totals {
 
 645   my ($self, %params) = @_;
 
 647   if ( $params{part_type} eq 'assortment' ) {
 
 648     return 0 unless scalar @{$self->assortment_items};
 
 649   } elsif ( $params{part_type} eq 'assembly' ) {
 
 650     return 0 unless scalar @{$self->assembly_items};
 
 652     carp "can only calculate sum for assortments and assemblies";
 
 655   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 656   if ( $part->is_assortment ) {
 
 657     $part->assortment_items( @{$self->assortment_items} );
 
 658     if ( $params{price_type} eq 'lastcost' ) {
 
 659       return $part->items_lastcost_sum;
 
 661       if ( $params{pricegroup_id} ) {
 
 662         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 664         return $part->items_sellprice_sum;
 
 667   } elsif ( $part->is_assembly ) {
 
 668     $part->assemblies( @{$self->assembly_items} );
 
 669     if ( $params{price_type} eq 'lastcost' ) {
 
 670       return $part->items_lastcost_sum;
 
 672       return $part->items_sellprice_sum;
 
 677 sub check_part_not_modified {
 
 680   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 687   my $is_new = !$self->part->id;
 
 689   my $params = delete($::form->{part}) || { };
 
 691   delete $params->{id};
 
 692   $self->part->assign_attributes(%{ $params});
 
 693   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 695   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 696   # will be the case for used assortments when saving, or when a used assortment
 
 698   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 699     $self->part->assortment_items([]);
 
 700     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 703   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 704     $self->part->assemblies([]); # completely rewrite assortments each time
 
 705     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 708   $self->part->translations([]);
 
 709   $self->parse_form_translations;
 
 711   $self->part->prices([]);
 
 712   $self->parse_form_prices;
 
 714   $self->parse_form_makemodels;
 
 717 sub parse_form_prices {
 
 719   # only save prices > 0
 
 720   my $prices = delete($::form->{prices}) || [];
 
 721   foreach my $price ( @{$prices} ) {
 
 722     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 723     next unless $sellprice > 0; # skip negative prices as well
 
 724     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 725                                pricegroup_id => $price->{pricegroup_id},
 
 728     $self->part->add_prices($p);
 
 732 sub parse_form_translations {
 
 734   # don't add empty translations
 
 735   my $translations = delete($::form->{translations}) || [];
 
 736   foreach my $translation ( @{$translations} ) {
 
 737     next unless $translation->{translation};
 
 738     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 739     $self->part->add_translations( $translation );
 
 743 sub parse_form_makemodels {
 
 747   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 748     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 751   $self->part->makemodels([]);
 
 754   my $makemodels = delete($::form->{makemodels}) || [];
 
 755   foreach my $makemodel ( @{$makemodels} ) {
 
 756     next unless $makemodel->{make};
 
 758     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 760     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 761                                      id         => $makemodel->{id},
 
 762                                      make       => $makemodel->{make},
 
 763                                      model      => $makemodel->{model} || '',
 
 764                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 765                                      sortorder  => $position,
 
 767     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 768       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 769       # don't change lastupdate
 
 770     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 771       # new makemodel, no lastcost entered, leave lastupdate empty
 
 772     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 773       # lastcost hasn't changed, use original lastupdate
 
 774       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 776       $mm->lastupdate(DateTime->now);
 
 778     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 779     $self->part->add_makemodels($mm);
 
 783 sub build_bin_select {
 
 784   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 785     title_key => 'description',
 
 786     default   => $_[0]->bin->id,
 
 790 # get_set_inits for partpicker
 
 793   if ($::form->{no_paginate}) {
 
 794     $_[0]->models->disable_plugin('paginated');
 
 800 # get_set_inits for part controller
 
 804   # used by edit, save, delete and add
 
 806   if ( $::form->{part}{id} ) {
 
 807     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 809     die "part_type missing" unless $::form->{part}{part_type};
 
 810     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 816   return $::auth->assert('assembly_edit', 1) // $self->part->orphaned;
 
 822   SL::Controller::Helper::GetModels->new(
 
 829       partnumber  => t8('Partnumber'),
 
 830       description  => t8('Description'),
 
 832     with_objects => [ qw(unit_obj classification) ],
 
 841 sub init_assortment_items {
 
 842   # this init is used while saving and whenever assortments change dynamically
 
 846   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 847   foreach my $assortment_item ( @{$assortment_items} ) {
 
 848     next unless $assortment_item->{parts_id};
 
 850     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 851     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 852                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 853                                           charge        => $assortment_item->{charge},
 
 854                                           unit          => $assortment_item->{unit} || $part->unit,
 
 855                                           position      => $position,
 
 863 sub init_makemodels {
 
 867   my @makemodel_array = ();
 
 868   my $makemodels = delete($::form->{makemodels}) || [];
 
 870   foreach my $makemodel ( @{$makemodels} ) {
 
 871     next unless $makemodel->{make};
 
 873     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 874                                     id        => $makemodel->{id},
 
 875                                     make      => $makemodel->{make},
 
 876                                     model     => $makemodel->{model} || '',
 
 877                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 878                                     sortorder => $position,
 
 879                                   ) or die "Can't create mm";
 
 880     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 881     push(@makemodel_array, $mm);
 
 883   return \@makemodel_array;
 
 886 sub init_assembly_items {
 
 890   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 891   foreach my $assembly_item ( @{$assembly_items} ) {
 
 892     next unless $assembly_item->{parts_id};
 
 894     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 895     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 896                                    bom         => $assembly_item->{bom},
 
 897                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 898                                    position    => $position,
 
 905 sub init_all_warehouses {
 
 907   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 910 sub init_all_languages {
 
 911   SL::DB::Manager::Language->get_all_sorted;
 
 914 sub init_all_partsgroups {
 
 916   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 919 sub init_all_buchungsgruppen {
 
 921   if ( $self->part->orphaned ) {
 
 922     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 924     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 928 sub init_shops_not_assigned {
 
 931   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
 932   if ( @used_shop_ids ) {
 
 933     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
 936     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
 942   if ( $self->part->orphaned ) {
 
 943     return SL::DB::Manager::Unit->get_all_sorted;
 
 945     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 949 sub init_all_payment_terms {
 
 951   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
 954 sub init_all_price_factors {
 
 955   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 958 sub init_all_pricegroups {
 
 959   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 962 # model used to filter/display the parts in the multi-items dialog
 
 963 sub init_multi_items_models {
 
 964   SL::Controller::Helper::GetModels->new(
 
 967     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
 968     disable_plugin => 'paginated',
 
 969     source         => $::form->{multi_items},
 
 975       partnumber  => t8('Partnumber'),
 
 976       description => t8('Description')}
 
 980 sub init_parts_classification_filter {
 
 981   return [] unless $::form->{parts_classification_type};
 
 983   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
 984   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
 986   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
 989 # simple checks to run on $::form before saving
 
 991 sub form_check_part_description_exists {
 
 994   return 1 if $::form->{part}{description};
 
 996   $self->js->flash('error', t8('Part Description missing!'))
 
 997            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 998            ->focus('#part_description');
 
1002 sub form_check_assortment_items_exist {
 
1005   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1006   # skip item check for existing assortments that have been used
 
1007   return 1 if ($self->part->id and !$self->part->orphaned);
 
1009   # new or orphaned parts must have items in $::form->{assortment_items}
 
1010   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1011     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1012              ->focus('#add_assortment_item_name')
 
1013              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1019 sub form_check_assortment_items_unique {
 
1022   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1024   my %duplicate_elements;
 
1026   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1027     $duplicate_elements{$_}++ if $count{$_}++;
 
1030   if ( keys %duplicate_elements ) {
 
1031     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1032              ->flash('error', t8('There are duplicate assortment items'));
 
1038 sub form_check_assembly_items_exist {
 
1041   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1043   # skip item check for existing assembly that have been used
 
1044   return 1 if ($self->part->id and !$self->part->orphaned);
 
1046   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1047     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1048              ->focus('#add_assembly_item_name')
 
1049              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1055 sub form_check_partnumber_is_unique {
 
1058   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1059     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1061       $self->js->flash('error', t8('The partnumber already exists!'))
 
1062                ->focus('#part_description');
 
1069 # general checking functions
 
1072   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1078   $self->form_check_part_description_exists || return 0;
 
1079   $self->form_check_assortment_items_exist  || return 0;
 
1080   $self->form_check_assortment_items_unique || return 0;
 
1081   $self->form_check_assembly_items_exist    || return 0;
 
1082   $self->form_check_partnumber_is_unique    || return 0;
 
1087 sub check_has_valid_part_type {
 
1088   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1091 sub render_assortment_items_to_html {
 
1092   my ($self, $assortment_items, $number_of_items) = @_;
 
1094   my $position = $number_of_items + 1;
 
1096   foreach my $ai (@$assortment_items) {
 
1097     $html .= $self->p->render('part/_assortment_row',
 
1098                               PART     => $self->part,
 
1099                               orphaned => $self->orphaned,
 
1101                               listrow  => $position % 2 ? 1 : 0,
 
1102                               position => $position, # for legacy assemblies
 
1109 sub render_assembly_items_to_html {
 
1110   my ($self, $assembly_items, $number_of_items) = @_;
 
1112   my $position = $number_of_items + 1;
 
1114   foreach my $ai (@{$assembly_items}) {
 
1115     $html .= $self->p->render('part/_assembly_row',
 
1116                               PART     => $self->part,
 
1117                               orphaned => $self->orphaned,
 
1119                               listrow  => $position % 2 ? 1 : 0,
 
1120                               position => $position, # for legacy assemblies
 
1127 sub parse_add_items_to_objects {
 
1128   my ($self, %params) = @_;
 
1129   my $part_type = $params{part_type};
 
1130   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1131   my $position = $params{position} || 1;
 
1133   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1136   foreach my $item ( @add_items ) {
 
1137     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1139     if ( $part_type eq 'assortment' ) {
 
1140        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1141                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1142                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1143                                          position      => $position,
 
1144                                         ) or die "Can't create AssortmentItem from item";
 
1145     } elsif ( $part_type eq 'assembly' ) {
 
1146       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1147                                  # id          => $self->assembly->id, # will be set on save
 
1148                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1149                                  bom         => 0, # default when adding: no bom
 
1150                                  position    => $position,
 
1153       die "part_type must be assortment or assembly";
 
1155     push(@item_objects, $ai);
 
1159   return \@item_objects;
 
1162 sub _setup_form_action_bar {
 
1165   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1167   for my $bar ($::request->layout->get('actionbar')) {
 
1172           call      => [ 'kivi.Part.save' ],
 
1173           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1174           accesskey => 'enter',
 
1178           call     => [ 'kivi.Part.use_as_new' ],
 
1179           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1180                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1183       ], # end of combobox "Save"
 
1187         call     => [ 'kivi.Part.delete' ],
 
1188         confirm  => t8('Do you really want to delete this object?'),
 
1189         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1190                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1191                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1199         call     => [ 'kivi.Part.open_history_popup' ],
 
1200         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1201                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1216 SL::Controller::Part - Part CRUD controller
 
1220 Controller for adding/editing/saving/deleting parts.
 
1222 All the relations are loaded at once and saving the part, adding a history
 
1223 entry and saving CVars happens inside one transaction.  When saving the old
 
1224 relations are deleted and written as new to the database.
 
1226 Relations for parts:
 
1234 =item assembly items
 
1236 =item assortment items
 
1244 There are 4 different part types:
 
1250 The "default" part type.
 
1252 inventory_accno_id is set.
 
1256 Services can't be stocked.
 
1258 inventory_accno_id isn't set.
 
1262 Assemblies consist of other parts, services, assemblies or assortments. They
 
1263 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1264 have to make them, which reduces the stock by its respective components. Once
 
1265 an assembly item has been created there is currently no way to "disassemble" it
 
1266 again. An assembly item can appear several times in one assembly. An assmbly is
 
1267 sold as one item with a defined sellprice and lastcost. If the component prices
 
1268 change the assortment price remains the same. The assembly items may be printed
 
1269 in a record if the item's "bom" is set.
 
1273 Similar to assembly, but each assortment item may only appear once per
 
1274 assortment. When selling an assortment the assortment items are added to the
 
1275 record together with the assortment, which is added with sellprice 0.
 
1277 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1278 determined by the sum of the current assortment item prices when the assortment
 
1279 is added to a record. This also means that price rules and customer discounts
 
1280 will be applied to the assortment items.
 
1282 Once the assortment items have been added they may be modified or deleted, just
 
1283 as if they had been added manually, the individual assortment items aren't
 
1284 linked to the assortment or the other assortment items in any way.
 
1292 =item C<action_add_part>
 
1294 =item C<action_add_service>
 
1296 =item C<action_add_assembly>
 
1298 =item C<action_add_assortment>
 
1300 =item C<action_add PART_TYPE>
 
1302 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1303 parameter part_type as an action. Example:
 
1305   controller.pl?action=Part/add&part_type=service
 
1307 =item C<action_add_from_record>
 
1309 When adding new items to records they can be created on the fly if the entered
 
1310 partnumber or description doesn't exist yet. After being asked what part type
 
1311 the new item should have the user is redirected to the correct edit page.
 
1313 Depending on whether the item was added from a sales or a purchase record, only
 
1314 the relevant part classifications should be selectable for new item, so this
 
1315 parameter is passed on via a hidden parts_classification_type in the new_item
 
1318 =item C<action_save>
 
1320 Saves the current part and then reloads the edit page for the part.
 
1322 =item C<action_use_as_new>
 
1324 Takes the information from the current part, plus any modifications made on the
 
1325 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1326 set empty, so a new partnumber from the number range will be used if the user
 
1327 doesn't enter one manually.
 
1329 Unsaved changes to the original part aren't updated.
 
1331 The part type cannot be changed in this way.
 
1333 =item C<action_delete>
 
1335 Deletes the current part and then redirects to the main page, there is no
 
1338 The delete button only appears if the part is 'orphaned', according to
 
1339 SL::DB::Part orphaned.
 
1341 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1342 the inventory, or is part of an assembly or assortment.
 
1344 If the part is deleted its relations prices, makdemodel, assembly,
 
1345 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1347 Before this controller items that appeared in inventory didn't count as
 
1348 orphaned and could be deleted and the inventory entries were also deleted, this
 
1349 "feature" hasn't been implemented.
 
1351 =item C<action_edit part.id>
 
1353 Load and display a part for editing.
 
1355   controller.pl?action=Part/edit&part.id=12345
 
1357 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1361 =head1 BUTTON ACTIONS
 
1367 Opens a popup displaying all the history entries. Once a new history controller
 
1368 is written the button could link there instead, with the part already selected.
 
1376 =item C<action_update_item_totals>
 
1378 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1379 amount of an item changes. The sum of all sellprices and lastcosts is
 
1380 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1382 =item C<action_add_assortment_item>
 
1384 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1386 If the item already exists in the assortment the item isn't added and a Flash
 
1389 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1390 after adding each new item, add the new object to the item objects that were
 
1391 already parsed, calculate totals via a dummy part then update the row and the
 
1394 =item C<action_add_assembly_item>
 
1396 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1398 If the item already exists in the assembly a flash info is generated, but the
 
1401 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1402 after adding each new item, add the new object to the item objects that were
 
1403 already parsed, calculate totals via a dummy part then update the row and the
 
1406 =item C<action_add_multi_assortment_items>
 
1408 Parses the items to be added from the form generated by the multi input and
 
1409 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1410 assortment items are renumbered and the sums recalculated via
 
1411 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1413 =item C<action_add_multi_assembly_items>
 
1415 Parses the items to be added from the form generated by the multi input and
 
1416 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1417 assembly items are renumbered and the sums recalculated via
 
1418 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1420 =item C<action_show_multi_items_dialog>
 
1422 =item C<action_multi_items_update_result>
 
1424 =item C<action_add_makemodel_row>
 
1426 Add a new makemodel row with the vendor that was selected via the vendor
 
1429 Checks the already existing makemodels and warns if a row with that vendor
 
1430 already exists. Currently it is possible to have duplicate vendor rows.
 
1432 =item C<action_reorder_items>
 
1434 Sorts the item table for assembly or assortment items.
 
1436 =item C<action_warehouse_changed>
 
1440 =head1 ACTIONS part picker
 
1444 =item C<action_ajax_autocomplete>
 
1446 =item C<action_test_page>
 
1448 =item C<action_part_picker_search>
 
1450 =item C<action_part_picker_result>
 
1452 =item C<action_show>
 
1462 Calls some simple checks that test the submitted $::form for obvious errors.
 
1463 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1465 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1466 some cases extra actions are taken, e.g. if the part description is missing the
 
1467 basic data tab is selected and the description input field is focussed.
 
1473 =item C<form_check_part_description_exists>
 
1475 =item C<form_check_assortment_items_exist>
 
1477 =item C<form_check_assortment_items_unique>
 
1479 =item C<form_check_assembly_items_exist>
 
1481 =item C<form_check_partnumber_is_unique>
 
1485 =head1 HELPER FUNCTIONS
 
1491 When submitting the form for saving, parses the transmitted form. Expects the
 
1495  $::form->{makemodels}
 
1496  $::form->{translations}
 
1498  $::form->{assemblies}
 
1499  $::form->{assortments}
 
1501 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1503 =item C<recalc_item_totals %params>
 
1505 Helper function for calculating the total lastcost and sellprice for assemblies
 
1506 or assortments according to their items, which are parsed from the current
 
1509 Is called whenever the qty of an item is changed or items are deleted.
 
1513 * part_type : 'assortment' or 'assembly' (mandatory)
 
1515 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1517 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1519 Doesn't work for recursive items.
 
1523 =head1 GET SET INITS
 
1525 There are get_set_inits for
 
1533 which parse $::form and automatically create an array of objects.
 
1535 These inits are used during saving and each time a new element is added.
 
1539 =item C<init_makemodels>
 
1541 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1542 $self->part->makemodels, ready to be saved.
 
1544 Used for saving parts and adding new makemodel rows.
 
1546 =item C<parse_add_items_to_objects PART_TYPE>
 
1548 Parses the resulting form from either the part-picker submit or the multi-item
 
1549 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1550 can be rendered via C<render_assortment_items_to_html> or
 
1551 C<render_assembly_items_to_html>.
 
1553 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1554 Optional param: position (used for numbering and listrow class)
 
1556 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1558 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1559 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1560 assortment items are added.
 
1562 =item C<parse_form_makemodels>
 
1564 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1565 remembers when the lastcost for that vendor changed the last time.
 
1567 So the original values are cloned and remembered, so we can compare if lastcost
 
1568 was changed in $::form, and keep or update lastupdate.
 
1570 lastcost isn't updated until the first time it was saved with a value, until
 
1573 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1574 makemodel entries exist or not.
 
1576 We still need init_makemodels for when we open the part for editing.
 
1586 It should be possible to jump to the edit page in a specific tab
 
1590 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1591 back to the order again afterwards.
 
1595 Support units when adding assembly items or assortment items. Currently the
 
1596 default unit of the item is always used.
 
1600 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1601 consists of other assemblies.
 
1607 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>