1 package SL::Controller::Part;
 
   4 use parent qw(SL::Controller::Base);
 
   8 use SL::DB::PartsGroup;
 
   9 use SL::Controller::Helper::GetModels;
 
  10 use SL::Locale::String qw(t8);
 
  12 use List::Util qw(sum);
 
  13 use SL::Helper::Flash;
 
  17 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  19 use SL::MoreCommon qw(save_form);
 
  22 use Rose::Object::MakeMethods::Generic (
 
  23   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  26                                   assortment assortment_items assembly assembly_items
 
  27                                   all_pricegroups all_translations all_partsgroups all_units
 
  28                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  29                                   parts_classification_filter
 
  30                                   all_languages all_units all_price_factors) ],
 
  31   'scalar'                => [ qw(warehouse bin) ],
 
  35 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  36                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  38 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  40 # actions for editing parts
 
  43   my ($self, %params) = @_;
 
  45   $self->part( SL::DB::Part->new_part );
 
  49 sub action_add_service {
 
  50   my ($self, %params) = @_;
 
  52   $self->part( SL::DB::Part->new_service );
 
  56 sub action_add_assembly {
 
  57   my ($self, %params) = @_;
 
  59   $self->part( SL::DB::Part->new_assembly );
 
  63 sub action_add_assortment {
 
  64   my ($self, %params) = @_;
 
  66   $self->part( SL::DB::Part->new_assortment );
 
  70 sub action_add_from_record {
 
  73   check_has_valid_part_type($::form->{part}{part_type});
 
  75   die 'parts_classification_type must be "sales" or "purchases"'
 
  76     unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
 
  85   check_has_valid_part_type($::form->{part_type});
 
  87   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  88   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  89   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  90   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  94   my ($self, %params) = @_;
 
  96   # checks that depend only on submitted $::form
 
  97   $self->check_form or return $self->js->render;
 
  99   my $is_new = !$self->part->id; # $ part gets loaded here
 
 101   # check that the part hasn't been modified
 
 103     $self->check_part_not_modified or
 
 104       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;
 
 108        && $::form->{part}{partnumber}
 
 109        && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
 
 111     return $self->js->error(t8('The partnumber is already being used'))->render;
 
 116   my @errors = $self->part->validate;
 
 117   return $self->js->error(@errors)->render if @errors;
 
 119   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 120   $self->part->db->with_transaction(sub {
 
 122     if ( $params{save_as_new} ) {
 
 123       $self->part( $self->part->clone_and_reset_deep );
 
 124       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 127     $self->part->save(cascade => 1);
 
 129     SL::DB::History->new(
 
 130       trans_id    => $self->part->id,
 
 131       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 132       employee_id => SL::DB::Manager::Employee->current->id,
 
 137     CVar->save_custom_variables(
 
 138         dbh          => $self->part->db->dbh,
 
 140         trans_id     => $self->part->id,
 
 141         variables    => $::form, # $::form->{cvar} would be nicer
 
 146   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 149   flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
 
 151   if ( $::form->{callback} ) {
 
 152     $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
 
 155     # default behaviour after save: reload item, this also resets last_modification!
 
 156     $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 160 sub action_save_as_new {
 
 162   $self->action_save(save_as_new=>1);
 
 168   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 170   my $partnumber = $self->part->partnumber; # remember for history log
 
 175       # delete part, together with relationships that don't already
 
 176       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 177       $self->part->delete(cascade => 1);
 
 179       SL::DB::History->new(
 
 180         trans_id    => $self->part->id,
 
 181         snumbers    => 'partnumber_' . $partnumber,
 
 182         employee_id => SL::DB::Manager::Employee->current->id,
 
 184         addition    => 'DELETED',
 
 187   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 189   flash_later('info', t8('The item has been deleted.'));
 
 190   if ( $::form->{callback} ) {
 
 191     $self->redirect_to($::form->unescape($::form->{callback}));
 
 193     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 197 sub action_use_as_new {
 
 198   my ($self, %params) = @_;
 
 200   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 201   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 203   $self->part($oldpart->clone_and_reset_deep);
 
 205   $self->part->partnumber(undef);
 
 211   my ($self, %params) = @_;
 
 217   my ($self, %params) = @_;
 
 219   $self->_set_javascript;
 
 220   $self->_setup_form_action_bar;
 
 222   my (%assortment_vars, %assembly_vars);
 
 223   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 224   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 226   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 228   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 229     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 231   my %title_hash = ( part       => t8('Edit Part'),
 
 232                      assembly   => t8('Edit Assembly'),
 
 233                      service    => t8('Edit Service'),
 
 234                      assortment => t8('Edit Assortment'),
 
 237   $self->part->prices([])       unless $self->part->prices;
 
 238   $self->part->translations([]) unless $self->part->translations;
 
 242     title             => $title_hash{$self->part->part_type},
 
 245     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 246     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 247     oldpartnumber     => $::form->{oldpartnumber},
 
 248     old_id            => $::form->{old_id},
 
 256   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 257   $_[0]->render('part/history', { layout => 0 },
 
 258                                   history_entries => $history_entries);
 
 261 sub action_update_item_totals {
 
 264   my $part_type = $::form->{part_type};
 
 265   die unless $part_type =~ /^(assortment|assembly)$/;
 
 267   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 268   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 270   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 273     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 274     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 275     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 276     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 277     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 278     ->no_flash_clear->render();
 
 281 sub action_add_multi_assortment_items {
 
 284   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 285   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 287   $self->js->run('kivi.Part.close_picker_dialogs')
 
 288            ->append('#assortment_rows', $html)
 
 289            ->run('kivi.Part.renumber_positions')
 
 290            ->run('kivi.Part.assortment_recalc')
 
 294 sub action_add_multi_assembly_items {
 
 297   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 299   foreach my $item (@{$item_objects}) {
 
 300     my $errstr = validate_assembly($item->part,$self->part);
 
 301     $self->js->flash('error',$errstr) if     $errstr;
 
 302     push (@checked_objects,$item)     unless $errstr;
 
 305   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 307   $self->js->run('kivi.Part.close_picker_dialogs')
 
 308            ->append('#assembly_rows', $html)
 
 309            ->run('kivi.Part.renumber_positions')
 
 310            ->run('kivi.Part.assembly_recalc')
 
 314 sub action_add_assortment_item {
 
 315   my ($self, %params) = @_;
 
 317   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 319   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 321   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 322   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 323     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 326   my $number_of_items = scalar @{$self->assortment_items};
 
 327   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 328   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 330   push(@{$self->assortment_items}, @{$item_objects});
 
 331   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 332   $part->assortment_items(@{$self->assortment_items});
 
 333   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 334   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 335   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 338     ->append('#assortment_rows'        , $html)  # append in tbody
 
 339     ->val('.add_assortment_item_input' , '')
 
 340     ->run('kivi.Part.focus_last_assortment_input')
 
 341     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 342     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 343     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 344     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 345     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 349 sub action_add_assembly_item {
 
 352   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 354   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 356   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 358   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 359   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 360     $duplicate_warning++;
 
 363   my $number_of_items = scalar @{$self->assembly_items};
 
 364   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 366     foreach my $item (@{$item_objects}) {
 
 367       my $errstr = validate_assembly($item->part,$self->part);
 
 368       return $self->js->flash('error',$errstr)->render if $errstr;
 
 373   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 375   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 377   push(@{$self->assembly_items}, @{$item_objects});
 
 378   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 379   $part->assemblies(@{$self->assembly_items});
 
 380   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 381   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 382   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 385     ->append('#assembly_rows', $html)  # append in tbody
 
 386     ->val('.add_assembly_item_input' , '')
 
 387     ->run('kivi.Part.focus_last_assembly_input')
 
 388     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 389     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 390     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 391     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 392     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 396 sub action_show_multi_items_dialog {
 
 397   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 398     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 402 sub action_multi_items_update_result {
 
 405   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 407   my $count = $_[0]->multi_items_models->count;
 
 410     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 411     $_[0]->render($text, { layout => 0 });
 
 412   } elsif ($count > $max_count) {
 
 413     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 414     $_[0]->render($text, { layout => 0 });
 
 416     my $multi_items = $_[0]->multi_items_models->get;
 
 417     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 418                   multi_items => $multi_items);
 
 422 sub action_add_makemodel_row {
 
 425   my $vendor_id = $::form->{add_makemodel};
 
 427   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 428     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 430   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 431     $self->js->flash('info', t8("This vendor has already been added."));
 
 434   my $position = scalar @{$self->makemodels} + 1;
 
 436   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 440                                   sortorder    => $position,
 
 441                                  ) or die "Can't create MakeModel object";
 
 443   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 445                                      listrow   => $position % 2 ? 0 : 1,
 
 448   # after selection focus on the model field in the row that was just added
 
 450     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 451     ->val('.add_makemodel_input', '')
 
 452     ->run('kivi.Part.focus_last_makemodel_input')
 
 456 sub action_reorder_items {
 
 459   my $part_type = $::form->{part_type};
 
 462     partnumber  => sub { $_[0]->part->partnumber },
 
 463     description => sub { $_[0]->part->description },
 
 464     qty         => sub { $_[0]->qty },
 
 465     sellprice   => sub { $_[0]->part->sellprice },
 
 466     lastcost    => sub { $_[0]->part->lastcost },
 
 467     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 470   my $method = $sort_keys{$::form->{order_by}};
 
 473   if ($part_type eq 'assortment') {
 
 474     @items = @{ $self->assortment_items };
 
 476     @items = @{ $self->assembly_items };
 
 479   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 480   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 481     if ($::form->{sort_dir}) {
 
 482       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 484       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 487     if ($::form->{sort_dir}) {
 
 488       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 490       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 494   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 497 sub action_warehouse_changed {
 
 500   if ($::form->{warehouse_id} ) {
 
 501     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 502     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 504     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 505       $self->bin($self->warehouse->bins->[0]);
 
 507         ->html('#bin', $self->build_bin_select)
 
 508         ->focus('#part_bin_id');
 
 509       return $self->js->render;
 
 513   # no warehouse was selected, empty the bin field and reset the id
 
 515        ->val('#part_bin_id', undef)
 
 518   return $self->js->render;
 
 521 sub action_ajax_autocomplete {
 
 522   my ($self, %params) = @_;
 
 524   # if someone types something, and hits enter, assume he entered the full name.
 
 525   # if something matches, treat that as sole match
 
 526   # since we need a second get models instance with different filters for that,
 
 527   # we only modify the original filter temporarily in place
 
 528   if ($::form->{prefer_exact}) {
 
 529     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 531     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 534       paginated    => { per_page => 2 },
 
 535       with_objects => [ qw(unit_obj classification) ],
 
 538     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 539       $self->parts($exact_matches);
 
 545      value       => $_->displayable_name,
 
 546      label       => $_->displayable_name,
 
 548      partnumber  => $_->partnumber,
 
 549      description => $_->description,
 
 550      part_type   => $_->part_type,
 
 552      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 554   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 556   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 559 sub action_test_page {
 
 560   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 563 sub action_part_picker_search {
 
 564   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 567 sub action_part_picker_result {
 
 568   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 574   if ($::request->type eq 'json') {
 
 579       $part_hash          = $self->part->as_tree;
 
 580       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 583     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 588 sub validate_add_items {
 
 589   scalar @{$::form->{add_items}};
 
 592 sub prepare_assortment_render_vars {
 
 595   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 596                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 597                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 599   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 604 sub prepare_assembly_render_vars {
 
 607   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 608                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 609                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 611   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 619   check_has_valid_part_type($self->part->part_type);
 
 621   $self->_set_javascript;
 
 622   $self->_setup_form_action_bar;
 
 624   my %title_hash = ( part       => t8('Add Part'),
 
 625                      assembly   => t8('Add Assembly'),
 
 626                      service    => t8('Add Service'),
 
 627                      assortment => t8('Add Assortment'),
 
 632     title => $title_hash{$self->part->part_type},
 
 637 sub _set_javascript {
 
 639   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
 
 640   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 643 sub recalc_item_totals {
 
 644   my ($self, %params) = @_;
 
 646   if ( $params{part_type} eq 'assortment' ) {
 
 647     return 0 unless scalar @{$self->assortment_items};
 
 648   } elsif ( $params{part_type} eq 'assembly' ) {
 
 649     return 0 unless scalar @{$self->assembly_items};
 
 651     carp "can only calculate sum for assortments and assemblies";
 
 654   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 655   if ( $part->is_assortment ) {
 
 656     $part->assortment_items( @{$self->assortment_items} );
 
 657     if ( $params{price_type} eq 'lastcost' ) {
 
 658       return $part->items_lastcost_sum;
 
 660       if ( $params{pricegroup_id} ) {
 
 661         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 663         return $part->items_sellprice_sum;
 
 666   } elsif ( $part->is_assembly ) {
 
 667     $part->assemblies( @{$self->assembly_items} );
 
 668     if ( $params{price_type} eq 'lastcost' ) {
 
 669       return $part->items_lastcost_sum;
 
 671       return $part->items_sellprice_sum;
 
 676 sub check_part_not_modified {
 
 679   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 686   my $is_new = !$self->part->id;
 
 688   my $params = delete($::form->{part}) || { };
 
 690   delete $params->{id};
 
 691   $self->part->assign_attributes(%{ $params});
 
 692   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 694   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 695   # will be the case for used assortments when saving, or when a used assortment
 
 697   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 698     $self->part->assortment_items([]);
 
 699     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 702   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 703     $self->part->assemblies([]); # completely rewrite assortments each time
 
 704     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 707   $self->part->translations([]);
 
 708   $self->parse_form_translations;
 
 710   $self->part->prices([]);
 
 711   $self->parse_form_prices;
 
 713   $self->parse_form_makemodels;
 
 716 sub parse_form_prices {
 
 718   # only save prices > 0
 
 719   my $prices = delete($::form->{prices}) || [];
 
 720   foreach my $price ( @{$prices} ) {
 
 721     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 722     next unless $sellprice > 0; # skip negative prices as well
 
 723     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 724                                pricegroup_id => $price->{pricegroup_id},
 
 727     $self->part->add_prices($p);
 
 731 sub parse_form_translations {
 
 733   # don't add empty translations
 
 734   my $translations = delete($::form->{translations}) || [];
 
 735   foreach my $translation ( @{$translations} ) {
 
 736     next unless $translation->{translation};
 
 737     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 738     $self->part->add_translations( $translation );
 
 742 sub parse_form_makemodels {
 
 746   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 747     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 750   $self->part->makemodels([]);
 
 753   my $makemodels = delete($::form->{makemodels}) || [];
 
 754   foreach my $makemodel ( @{$makemodels} ) {
 
 755     next unless $makemodel->{make};
 
 757     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 759     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 760                                      id         => $makemodel->{id},
 
 761                                      make       => $makemodel->{make},
 
 762                                      model      => $makemodel->{model} || '',
 
 763                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 764                                      sortorder  => $position,
 
 766     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 767       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 768       # don't change lastupdate
 
 769     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 770       # new makemodel, no lastcost entered, leave lastupdate empty
 
 771     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 772       # lastcost hasn't changed, use original lastupdate
 
 773       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 775       $mm->lastupdate(DateTime->now);
 
 777     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 778     $self->part->add_makemodels($mm);
 
 782 sub build_bin_select {
 
 783   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 784     title_key => 'description',
 
 785     default   => $_[0]->bin->id,
 
 789 # get_set_inits for partpicker
 
 792   if ($::form->{no_paginate}) {
 
 793     $_[0]->models->disable_plugin('paginated');
 
 799 # get_set_inits for part controller
 
 803   # used by edit, save, delete and add
 
 805   if ( $::form->{part}{id} ) {
 
 806     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
 
 808     die "part_type missing" unless $::form->{part}{part_type};
 
 809     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 815   return $self->part->orphaned;
 
 821   SL::Controller::Helper::GetModels->new(
 
 828       partnumber  => t8('Partnumber'),
 
 829       description  => t8('Description'),
 
 831     with_objects => [ qw(unit_obj classification) ],
 
 840 sub init_assortment_items {
 
 841   # this init is used while saving and whenever assortments change dynamically
 
 845   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 846   foreach my $assortment_item ( @{$assortment_items} ) {
 
 847     next unless $assortment_item->{parts_id};
 
 849     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 850     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 851                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 852                                           charge        => $assortment_item->{charge},
 
 853                                           unit          => $assortment_item->{unit} || $part->unit,
 
 854                                           position      => $position,
 
 862 sub init_makemodels {
 
 866   my @makemodel_array = ();
 
 867   my $makemodels = delete($::form->{makemodels}) || [];
 
 869   foreach my $makemodel ( @{$makemodels} ) {
 
 870     next unless $makemodel->{make};
 
 872     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 873                                     id        => $makemodel->{id},
 
 874                                     make      => $makemodel->{make},
 
 875                                     model     => $makemodel->{model} || '',
 
 876                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 877                                     sortorder => $position,
 
 878                                   ) or die "Can't create mm";
 
 879     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 880     push(@makemodel_array, $mm);
 
 882   return \@makemodel_array;
 
 885 sub init_assembly_items {
 
 889   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 890   foreach my $assembly_item ( @{$assembly_items} ) {
 
 891     next unless $assembly_item->{parts_id};
 
 893     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 894     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 895                                    bom         => $assembly_item->{bom},
 
 896                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 897                                    position    => $position,
 
 904 sub init_all_warehouses {
 
 906   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 909 sub init_all_languages {
 
 910   SL::DB::Manager::Language->get_all_sorted;
 
 913 sub init_all_partsgroups {
 
 915   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 918 sub init_all_buchungsgruppen {
 
 920   if ( $self->part->orphaned ) {
 
 921     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 923     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 929   if ( $self->part->orphaned ) {
 
 930     return SL::DB::Manager::Unit->get_all_sorted;
 
 932     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 936 sub init_all_payment_terms {
 
 938   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
 941 sub init_all_price_factors {
 
 942   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 945 sub init_all_pricegroups {
 
 946   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 949 # model used to filter/display the parts in the multi-items dialog
 
 950 sub init_multi_items_models {
 
 951   SL::Controller::Helper::GetModels->new(
 
 954     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
 955     disable_plugin => 'paginated',
 
 956     source         => $::form->{multi_items},
 
 962       partnumber  => t8('Partnumber'),
 
 963       description => t8('Description')}
 
 967 sub init_parts_classification_filter {
 
 968   return [] unless $::form->{parts_classification_type};
 
 970   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
 971   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
 973   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
 976 # simple checks to run on $::form before saving
 
 978 sub form_check_part_description_exists {
 
 981   return 1 if $::form->{part}{description};
 
 983   $self->js->flash('error', t8('Part Description missing!'))
 
 984            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 985            ->focus('#part_description');
 
 989 sub form_check_assortment_items_exist {
 
 992   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 993   # skip item check for existing assortments that have been used
 
 994   return 1 if ($self->part->id and !$self->part->orphaned);
 
 996   # new or orphaned parts must have items in $::form->{assortment_items}
 
 997   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
 998     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 999              ->focus('#add_assortment_item_name')
 
1000              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1006 sub form_check_assortment_items_unique {
 
1009   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1011   my %duplicate_elements;
 
1013   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1014     $duplicate_elements{$_}++ if $count{$_}++;
 
1017   if ( keys %duplicate_elements ) {
 
1018     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1019              ->flash('error', t8('There are duplicate assortment items'));
 
1025 sub form_check_assembly_items_exist {
 
1028   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1030   # skip item check for existing assembly that have been used
 
1031   return 1 if ($self->part->id and !$self->part->orphaned);
 
1033   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1034     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1035              ->focus('#add_assembly_item_name')
 
1036              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1042 sub form_check_partnumber_is_unique {
 
1045   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1046     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1048       $self->js->flash('error', t8('The partnumber already exists!'))
 
1049                ->focus('#part_description');
 
1056 # general checking functions
 
1059   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1065   $self->form_check_part_description_exists || return 0;
 
1066   $self->form_check_assortment_items_exist  || return 0;
 
1067   $self->form_check_assortment_items_unique || return 0;
 
1068   $self->form_check_assembly_items_exist    || return 0;
 
1069   $self->form_check_partnumber_is_unique    || return 0;
 
1074 sub check_has_valid_part_type {
 
1075   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1078 sub render_assortment_items_to_html {
 
1079   my ($self, $assortment_items, $number_of_items) = @_;
 
1081   my $position = $number_of_items + 1;
 
1083   foreach my $ai (@$assortment_items) {
 
1084     $html .= $self->p->render('part/_assortment_row',
 
1085                               PART     => $self->part,
 
1086                               orphaned => $self->orphaned,
 
1088                               listrow  => $position % 2 ? 1 : 0,
 
1089                               position => $position, # for legacy assemblies
 
1096 sub render_assembly_items_to_html {
 
1097   my ($self, $assembly_items, $number_of_items) = @_;
 
1099   my $position = $number_of_items + 1;
 
1101   foreach my $ai (@{$assembly_items}) {
 
1102     $html .= $self->p->render('part/_assembly_row',
 
1103                               PART     => $self->part,
 
1104                               orphaned => $self->orphaned,
 
1106                               listrow  => $position % 2 ? 1 : 0,
 
1107                               position => $position, # for legacy assemblies
 
1114 sub parse_add_items_to_objects {
 
1115   my ($self, %params) = @_;
 
1116   my $part_type = $params{part_type};
 
1117   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1118   my $position = $params{position} || 1;
 
1120   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1123   foreach my $item ( @add_items ) {
 
1124     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1126     if ( $part_type eq 'assortment' ) {
 
1127        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1128                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1129                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1130                                          position      => $position,
 
1131                                         ) or die "Can't create AssortmentItem from item";
 
1132     } elsif ( $part_type eq 'assembly' ) {
 
1133       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1134                                  # id          => $self->assembly->id, # will be set on save
 
1135                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1136                                  bom         => 0, # default when adding: no bom
 
1137                                  position    => $position,
 
1140       die "part_type must be assortment or assembly";
 
1142     push(@item_objects, $ai);
 
1146   return \@item_objects;
 
1149 sub _setup_form_action_bar {
 
1152   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1154   for my $bar ($::request->layout->get('actionbar')) {
 
1159           call      => [ 'kivi.Part.save' ],
 
1160           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1161           accesskey => 'enter',
 
1165           call     => [ 'kivi.Part.use_as_new' ],
 
1166           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1167                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1170       ], # end of combobox "Save"
 
1174         call     => [ 'kivi.Part.delete' ],
 
1175         confirm  => t8('Do you really want to delete this object?'),
 
1176         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1177                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1178                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1186         call     => [ 'kivi.Part.open_history_popup' ],
 
1187         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1188                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1203 SL::Controller::Part - Part CRUD controller
 
1207 Controller for adding/editing/saving/deleting parts.
 
1209 All the relations are loaded at once and saving the part, adding a history
 
1210 entry and saving CVars happens inside one transaction.  When saving the old
 
1211 relations are deleted and written as new to the database.
 
1213 Relations for parts:
 
1221 =item assembly items
 
1223 =item assortment items
 
1231 There are 4 different part types:
 
1237 The "default" part type.
 
1239 inventory_accno_id is set.
 
1243 Services can't be stocked.
 
1245 inventory_accno_id isn't set.
 
1249 Assemblies consist of other parts, services, assemblies or assortments. They
 
1250 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1251 have to make them, which reduces the stock by its respective components. Once
 
1252 an assembly item has been created there is currently no way to "disassemble" it
 
1253 again. An assembly item can appear several times in one assembly. An assmbly is
 
1254 sold as one item with a defined sellprice and lastcost. If the component prices
 
1255 change the assortment price remains the same. The assembly items may be printed
 
1256 in a record if the item's "bom" is set.
 
1260 Similar to assembly, but each assortment item may only appear once per
 
1261 assortment. When selling an assortment the assortment items are added to the
 
1262 record together with the assortment, which is added with sellprice 0.
 
1264 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1265 determined by the sum of the current assortment item prices when the assortment
 
1266 is added to a record. This also means that price rules and customer discounts
 
1267 will be applied to the assortment items.
 
1269 Once the assortment items have been added they may be modified or deleted, just
 
1270 as if they had been added manually, the individual assortment items aren't
 
1271 linked to the assortment or the other assortment items in any way.
 
1279 =item C<action_add_part>
 
1281 =item C<action_add_service>
 
1283 =item C<action_add_assembly>
 
1285 =item C<action_add_assortment>
 
1287 =item C<action_add PART_TYPE>
 
1289 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1290 parameter part_type as an action. Example:
 
1292   controller.pl?action=Part/add&part_type=service
 
1294 =item C<action_add_from_record>
 
1296 When adding new items to records they can be created on the fly if the entered
 
1297 partnumber or description doesn't exist yet. After being asked what part type
 
1298 the new item should have the user is redirected to the correct edit page.
 
1300 Depending on whether the item was added from a sales or a purchase record, only
 
1301 the relevant part classifications should be selectable for new item, so this
 
1302 parameter is passed on via a hidden parts_classification_type in the new_item
 
1305 =item C<action_save>
 
1307 Saves the current part and then reloads the edit page for the part.
 
1309 =item C<action_use_as_new>
 
1311 Takes the information from the current part, plus any modifications made on the
 
1312 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1313 set empty, so a new partnumber from the number range will be used if the user
 
1314 doesn't enter one manually.
 
1316 Unsaved changes to the original part aren't updated.
 
1318 The part type cannot be changed in this way.
 
1320 =item C<action_delete>
 
1322 Deletes the current part and then redirects to the main page, there is no
 
1325 The delete button only appears if the part is 'orphaned', according to
 
1326 SL::DB::Part orphaned.
 
1328 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1329 the inventory, or is part of an assembly or assortment.
 
1331 If the part is deleted its relations prices, makdemodel, assembly,
 
1332 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1334 Before this controller items that appeared in inventory didn't count as
 
1335 orphaned and could be deleted and the inventory entries were also deleted, this
 
1336 "feature" hasn't been implemented.
 
1338 =item C<action_edit part.id>
 
1340 Load and display a part for editing.
 
1342   controller.pl?action=Part/edit&part.id=12345
 
1344 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1348 =head1 BUTTON ACTIONS
 
1354 Opens a popup displaying all the history entries. Once a new history controller
 
1355 is written the button could link there instead, with the part already selected.
 
1363 =item C<action_update_item_totals>
 
1365 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1366 amount of an item changes. The sum of all sellprices and lastcosts is
 
1367 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1369 =item C<action_add_assortment_item>
 
1371 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1373 If the item already exists in the assortment the item isn't added and a Flash
 
1376 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1377 after adding each new item, add the new object to the item objects that were
 
1378 already parsed, calculate totals via a dummy part then update the row and the
 
1381 =item C<action_add_assembly_item>
 
1383 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1385 If the item already exists in the assembly a flash info is generated, but the
 
1388 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1389 after adding each new item, add the new object to the item objects that were
 
1390 already parsed, calculate totals via a dummy part then update the row and the
 
1393 =item C<action_add_multi_assortment_items>
 
1395 Parses the items to be added from the form generated by the multi input and
 
1396 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1397 assortment items are renumbered and the sums recalculated via
 
1398 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1400 =item C<action_add_multi_assembly_items>
 
1402 Parses the items to be added from the form generated by the multi input and
 
1403 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1404 assembly items are renumbered and the sums recalculated via
 
1405 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1407 =item C<action_show_multi_items_dialog>
 
1409 =item C<action_multi_items_update_result>
 
1411 =item C<action_add_makemodel_row>
 
1413 Add a new makemodel row with the vendor that was selected via the vendor
 
1416 Checks the already existing makemodels and warns if a row with that vendor
 
1417 already exists. Currently it is possible to have duplicate vendor rows.
 
1419 =item C<action_reorder_items>
 
1421 Sorts the item table for assembly or assortment items.
 
1423 =item C<action_warehouse_changed>
 
1427 =head1 ACTIONS part picker
 
1431 =item C<action_ajax_autocomplete>
 
1433 =item C<action_test_page>
 
1435 =item C<action_part_picker_search>
 
1437 =item C<action_part_picker_result>
 
1439 =item C<action_show>
 
1449 Calls some simple checks that test the submitted $::form for obvious errors.
 
1450 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1452 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1453 some cases extra actions are taken, e.g. if the part description is missing the
 
1454 basic data tab is selected and the description input field is focussed.
 
1460 =item C<form_check_part_description_exists>
 
1462 =item C<form_check_assortment_items_exist>
 
1464 =item C<form_check_assortment_items_unique>
 
1466 =item C<form_check_assembly_items_exist>
 
1468 =item C<form_check_partnumber_is_unique>
 
1472 =head1 HELPER FUNCTIONS
 
1478 When submitting the form for saving, parses the transmitted form. Expects the
 
1482  $::form->{makemodels}
 
1483  $::form->{translations}
 
1485  $::form->{assemblies}
 
1486  $::form->{assortments}
 
1488 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1490 =item C<recalc_item_totals %params>
 
1492 Helper function for calculating the total lastcost and sellprice for assemblies
 
1493 or assortments according to their items, which are parsed from the current
 
1496 Is called whenever the qty of an item is changed or items are deleted.
 
1500 * part_type : 'assortment' or 'assembly' (mandatory)
 
1502 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1504 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1506 Doesn't work for recursive items.
 
1510 =head1 GET SET INITS
 
1512 There are get_set_inits for
 
1520 which parse $::form and automatically create an array of objects.
 
1522 These inits are used during saving and each time a new element is added.
 
1526 =item C<init_makemodels>
 
1528 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1529 $self->part->makemodels, ready to be saved.
 
1531 Used for saving parts and adding new makemodel rows.
 
1533 =item C<parse_add_items_to_objects PART_TYPE>
 
1535 Parses the resulting form from either the part-picker submit or the multi-item
 
1536 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1537 can be rendered via C<render_assortment_items_to_html> or
 
1538 C<render_assembly_items_to_html>.
 
1540 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1541 Optional param: position (used for numbering and listrow class)
 
1543 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1545 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1546 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1547 assortment items are added.
 
1549 =item C<parse_form_makemodels>
 
1551 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1552 remembers when the lastcost for that vendor changed the last time.
 
1554 So the original values are cloned and remembered, so we can compare if lastcost
 
1555 was changed in $::form, and keep or update lastupdate.
 
1557 lastcost isn't updated until the first time it was saved with a value, until
 
1560 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1561 makemodel entries exist or not.
 
1563 We still need init_makemodels for when we open the part for editing.
 
1573 It should be possible to jump to the edit page in a specific tab
 
1577 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1578 back to the order again afterwards.
 
1582 Support units when adding assembly items or assortment items. Currently the
 
1583 default unit of the item is always used.
 
1587 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1588 consists of other assemblies.
 
1594 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>