1 package SL::Controller::Part;
 
   4 use parent qw(SL::Controller::Base);
 
   8 use SL::DB::PartsGroup;
 
   9 use SL::DB::PriceRuleItem;
 
  11 use SL::Controller::Helper::GetModels;
 
  12 use SL::Locale::String qw(t8);
 
  14 use List::Util qw(sum);
 
  15 use SL::Helper::Flash;
 
  19 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  21 use SL::MoreCommon qw(save_form);
 
  23 use SL::Presenter::EscapedText qw(escape is_escaped);
 
  24 use SL::Presenter::Tag qw(select_tag);
 
  26 use Rose::Object::MakeMethods::Generic (
 
  27   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  28                                   makemodels shops_not_assigned
 
  31                                   assortment assortment_items assembly assembly_items
 
  32                                   all_pricegroups all_translations all_partsgroups all_units
 
  33                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  34                                   parts_classification_filter
 
  35                                   all_languages all_units all_price_factors) ],
 
  36   'scalar'                => [ qw(warehouse bin) ],
 
  40 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  41                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  43 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  45 # actions for editing parts
 
  48   my ($self, %params) = @_;
 
  50   $self->part( SL::DB::Part->new_part );
 
  54 sub action_add_service {
 
  55   my ($self, %params) = @_;
 
  57   $self->part( SL::DB::Part->new_service );
 
  61 sub action_add_assembly {
 
  62   my ($self, %params) = @_;
 
  64   $self->part( SL::DB::Part->new_assembly );
 
  68 sub action_add_assortment {
 
  69   my ($self, %params) = @_;
 
  71   $self->part( SL::DB::Part->new_assortment );
 
  75 sub action_add_from_record {
 
  78   check_has_valid_part_type($::form->{part}{part_type});
 
  80   die 'parts_classification_type must be "sales" or "purchases"'
 
  81     unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
 
  90   check_has_valid_part_type($::form->{part_type});
 
  92   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  93   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  94   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  95   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  99   my ($self, %params) = @_;
 
 101   # checks that depend only on submitted $::form
 
 102   $self->check_form or return $self->js->render;
 
 104   my $is_new = !$self->part->id; # $ part gets loaded here
 
 106   # check that the part hasn't been modified
 
 108     $self->check_part_not_modified or
 
 109       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;
 
 113        && $::form->{part}{partnumber}
 
 114        && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
 
 116     return $self->js->error(t8('The partnumber is already being used'))->render;
 
 121   my @errors = $self->part->validate;
 
 122   return $self->js->error(@errors)->render if @errors;
 
 124   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 125   $self->part->db->with_transaction(sub {
 
 127     if ( $params{save_as_new} ) {
 
 128       $self->part( $self->part->clone_and_reset_deep );
 
 129       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 132     $self->part->save(cascade => 1);
 
 134     SL::DB::History->new(
 
 135       trans_id    => $self->part->id,
 
 136       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 137       employee_id => SL::DB::Manager::Employee->current->id,
 
 142     CVar->save_custom_variables(
 
 143         dbh          => $self->part->db->dbh,
 
 145         trans_id     => $self->part->id,
 
 146         variables    => $::form, # $::form->{cvar} would be nicer
 
 151   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 154   flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
 
 156   if ( $::form->{callback} ) {
 
 157     $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
 
 160     # default behaviour after save: reload item, this also resets last_modification!
 
 161     $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 165 sub action_save_as_new {
 
 167   $self->action_save(save_as_new=>1);
 
 173   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 175   my $partnumber = $self->part->partnumber; # remember for history log
 
 180       # delete part, together with relationships that don't already
 
 181       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 182       $self->part->delete(cascade => 1);
 
 184       SL::DB::History->new(
 
 185         trans_id    => $self->part->id,
 
 186         snumbers    => 'partnumber_' . $partnumber,
 
 187         employee_id => SL::DB::Manager::Employee->current->id,
 
 189         addition    => 'DELETED',
 
 192   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 194   flash_later('info', t8('The item has been deleted.'));
 
 195   if ( $::form->{callback} ) {
 
 196     $self->redirect_to($::form->unescape($::form->{callback}));
 
 198     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 202 sub action_use_as_new {
 
 203   my ($self, %params) = @_;
 
 205   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 206   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 208   $self->part($oldpart->clone_and_reset_deep);
 
 210   $self->part->partnumber(undef);
 
 216   my ($self, %params) = @_;
 
 222   my ($self, %params) = @_;
 
 224   $self->_set_javascript;
 
 225   $self->_setup_form_action_bar;
 
 227   my (%assortment_vars, %assembly_vars);
 
 228   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 229   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 231   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 232   $_->{valid}                = 1 for @{ $params{CUSTOM_VARIABLES} };
 
 234   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 235     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 237   my %title_hash = ( part       => t8('Edit Part'),
 
 238                      assembly   => t8('Edit Assembly'),
 
 239                      service    => t8('Edit Service'),
 
 240                      assortment => t8('Edit Assortment'),
 
 243   $self->part->prices([])       unless $self->part->prices;
 
 244   $self->part->translations([]) unless $self->part->translations;
 
 248     title             => $title_hash{$self->part->part_type},
 
 251     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 252     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 253     oldpartnumber     => $::form->{oldpartnumber},
 
 254     old_id            => $::form->{old_id},
 
 262   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 263   $_[0]->render('part/history', { layout => 0 },
 
 264                                   history_entries => $history_entries);
 
 267 sub action_update_item_totals {
 
 270   my $part_type = $::form->{part_type};
 
 271   die unless $part_type =~ /^(assortment|assembly)$/;
 
 273   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 274   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 276   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 279     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 280     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 281     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 282     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 283     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 284     ->no_flash_clear->render();
 
 287 sub action_add_multi_assortment_items {
 
 290   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 291   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 293   $self->js->run('kivi.Part.close_picker_dialogs')
 
 294            ->append('#assortment_rows', $html)
 
 295            ->run('kivi.Part.renumber_positions')
 
 296            ->run('kivi.Part.assortment_recalc')
 
 300 sub action_add_multi_assembly_items {
 
 303   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 305   foreach my $item (@{$item_objects}) {
 
 306     my $errstr = validate_assembly($item->part,$self->part);
 
 307     $self->js->flash('error',$errstr) if     $errstr;
 
 308     push (@checked_objects,$item)     unless $errstr;
 
 311   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 313   $self->js->run('kivi.Part.close_picker_dialogs')
 
 314            ->append('#assembly_rows', $html)
 
 315            ->run('kivi.Part.renumber_positions')
 
 316            ->run('kivi.Part.assembly_recalc')
 
 320 sub action_add_assortment_item {
 
 321   my ($self, %params) = @_;
 
 323   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 325   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 327   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 328   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 329     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 332   my $number_of_items = scalar @{$self->assortment_items};
 
 333   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 334   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 336   push(@{$self->assortment_items}, @{$item_objects});
 
 337   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 338   $part->assortment_items(@{$self->assortment_items});
 
 339   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 340   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 341   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 344     ->append('#assortment_rows'        , $html)  # append in tbody
 
 345     ->val('.add_assortment_item_input' , '')
 
 346     ->run('kivi.Part.focus_last_assortment_input')
 
 347     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 348     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 349     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 350     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 351     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 355 sub action_add_assembly_item {
 
 358   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 360   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 362   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 364   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 365   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 366     $duplicate_warning++;
 
 369   my $number_of_items = scalar @{$self->assembly_items};
 
 370   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 372     foreach my $item (@{$item_objects}) {
 
 373       my $errstr = validate_assembly($item->part,$self->part);
 
 374       return $self->js->flash('error',$errstr)->render if $errstr;
 
 379   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 381   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 383   push(@{$self->assembly_items}, @{$item_objects});
 
 384   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 385   $part->assemblies(@{$self->assembly_items});
 
 386   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 387   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 388   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 391     ->append('#assembly_rows', $html)  # append in tbody
 
 392     ->val('.add_assembly_item_input' , '')
 
 393     ->run('kivi.Part.focus_last_assembly_input')
 
 394     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 395     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 396     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 397     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 398     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 402 sub action_show_multi_items_dialog {
 
 403   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 404     all_partsgroups => SL::DB::Manager::PartsGroup->get_all
 
 408 sub action_multi_items_update_result {
 
 411   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 413   my $count = $_[0]->multi_items_models->count;
 
 416     my $text = escape($::locale->text('No results.'));
 
 417     $_[0]->render($text, { layout => 0 });
 
 418   } elsif ($count > $max_count) {
 
 419     my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 420     $_[0]->render($text, { layout => 0 });
 
 422     my $multi_items = $_[0]->multi_items_models->get;
 
 423     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 424                   multi_items => $multi_items);
 
 428 sub action_add_makemodel_row {
 
 431   my $vendor_id = $::form->{add_makemodel};
 
 433   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 434     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 436   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 437     $self->js->flash('info', t8("This vendor has already been added."));
 
 440   my $position = scalar @{$self->makemodels} + 1;
 
 442   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 446                                   sortorder    => $position,
 
 447                                  ) or die "Can't create MakeModel object";
 
 449   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 451                                      listrow   => $position % 2 ? 0 : 1,
 
 454   # after selection focus on the model field in the row that was just added
 
 456     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 457     ->val('.add_makemodel_input', '')
 
 458     ->run('kivi.Part.focus_last_makemodel_input')
 
 462 sub action_add_customerprice_row {
 
 465   my $customer_id = $::form->{add_customerprice};
 
 467   my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
 
 468     or return $self->js->error(t8("No customer selected or found!"))->render;
 
 470   if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
 
 471     $self->js->flash('info', t8("This customer has already been added."));
 
 474   my $position = scalar @{ $self->customerprices } + 1;
 
 476   my $cu = SL::DB::PartCustomerPrice->new(
 
 477                       customer_id         => $customer->id,
 
 478                       customer_partnumber => '',
 
 480                       sortorder           => $position,
 
 481   ) or die "Can't create Customerprice object";
 
 483   my $row_as_html = $self->p->render(
 
 484                                      'part/_customerprice_row',
 
 485                                       customerprice => $cu,
 
 486                                       listrow       => $position % 2 ? 0
 
 490   $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
 
 491            ->val('.add_customerprice_input', '')
 
 492            ->run('kivi.Part.focus_last_customerprice_input')->render;
 
 495 sub action_reorder_items {
 
 498   my $part_type = $::form->{part_type};
 
 501     partnumber  => sub { $_[0]->part->partnumber },
 
 502     description => sub { $_[0]->part->description },
 
 503     qty         => sub { $_[0]->qty },
 
 504     sellprice   => sub { $_[0]->part->sellprice },
 
 505     lastcost    => sub { $_[0]->part->lastcost },
 
 506     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 509   my $method = $sort_keys{$::form->{order_by}};
 
 512   if ($part_type eq 'assortment') {
 
 513     @items = @{ $self->assortment_items };
 
 515     @items = @{ $self->assembly_items };
 
 518   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 519   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 520     if ($::form->{sort_dir}) {
 
 521       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 523       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 526     if ($::form->{sort_dir}) {
 
 527       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 529       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 533   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 536 sub action_warehouse_changed {
 
 539   if ($::form->{warehouse_id} ) {
 
 540     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 541     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 543     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 544       $self->bin($self->warehouse->bins->[0]);
 
 546         ->html('#bin', $self->build_bin_select)
 
 547         ->focus('#part_bin_id');
 
 548       return $self->js->render;
 
 552   # no warehouse was selected, empty the bin field and reset the id
 
 554        ->val('#part_bin_id', undef)
 
 557   return $self->js->render;
 
 560 sub action_ajax_autocomplete {
 
 561   my ($self, %params) = @_;
 
 563   # if someone types something, and hits enter, assume he entered the full name.
 
 564   # if something matches, treat that as sole match
 
 565   # since we need a second get models instance with different filters for that,
 
 566   # we only modify the original filter temporarily in place
 
 567   if ($::form->{prefer_exact}) {
 
 568     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 570     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 573       paginated    => { per_page => 2 },
 
 574       with_objects => [ qw(unit_obj classification) ],
 
 577     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 578       $self->parts($exact_matches);
 
 584      value       => $_->displayable_name,
 
 585      label       => $_->displayable_name,
 
 587      partnumber  => $_->partnumber,
 
 588      description => $_->description,
 
 590      part_type   => $_->part_type,
 
 592      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 594   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 596   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 599 sub action_test_page {
 
 600   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 603 sub action_part_picker_search {
 
 604   $_[0]->render('part/part_picker_search', { layout => 0 });
 
 607 sub action_part_picker_result {
 
 608   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 614   if ($::request->type eq 'json') {
 
 619       $part_hash          = $self->part->as_tree;
 
 620       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 623     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 628 sub validate_add_items {
 
 629   scalar @{$::form->{add_items}};
 
 632 sub prepare_assortment_render_vars {
 
 635   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 636                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 637                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 639   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 644 sub prepare_assembly_render_vars {
 
 647   croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
 
 649   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 650                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 651                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 653   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 661   check_has_valid_part_type($self->part->part_type);
 
 663   $self->_set_javascript;
 
 664   $self->_setup_form_action_bar;
 
 666   my %title_hash = ( part       => t8('Add Part'),
 
 667                      assembly   => t8('Add Assembly'),
 
 668                      service    => t8('Add Service'),
 
 669                      assortment => t8('Add Assortment'),
 
 674     title => $title_hash{$self->part->part_type},
 
 679 sub _set_javascript {
 
 681   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
 
 682   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 685 sub recalc_item_totals {
 
 686   my ($self, %params) = @_;
 
 688   if ( $params{part_type} eq 'assortment' ) {
 
 689     return 0 unless scalar @{$self->assortment_items};
 
 690   } elsif ( $params{part_type} eq 'assembly' ) {
 
 691     return 0 unless scalar @{$self->assembly_items};
 
 693     carp "can only calculate sum for assortments and assemblies";
 
 696   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 697   if ( $part->is_assortment ) {
 
 698     $part->assortment_items( @{$self->assortment_items} );
 
 699     if ( $params{price_type} eq 'lastcost' ) {
 
 700       return $part->items_lastcost_sum;
 
 702       if ( $params{pricegroup_id} ) {
 
 703         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 705         return $part->items_sellprice_sum;
 
 708   } elsif ( $part->is_assembly ) {
 
 709     $part->assemblies( @{$self->assembly_items} );
 
 710     if ( $params{price_type} eq 'lastcost' ) {
 
 711       return $part->items_lastcost_sum;
 
 713       return $part->items_sellprice_sum;
 
 718 sub check_part_not_modified {
 
 721   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 728   my $is_new = !$self->part->id;
 
 730   my $params = delete($::form->{part}) || { };
 
 732   delete $params->{id};
 
 733   $self->part->assign_attributes(%{ $params});
 
 734   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 736   $self->normalize_text_blocks;
 
 738   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 739   # will be the case for used assortments when saving, or when a used assortment
 
 741   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 742     $self->part->assortment_items([]);
 
 743     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 746   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 747     $self->part->assemblies([]); # completely rewrite assortments each time
 
 748     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 751   $self->part->translations([]);
 
 752   $self->parse_form_translations;
 
 754   $self->part->prices([]);
 
 755   $self->parse_form_prices;
 
 757   $self->parse_form_customerprices;
 
 758   $self->parse_form_makemodels;
 
 761 sub parse_form_prices {
 
 763   # only save prices > 0
 
 764   my $prices = delete($::form->{prices}) || [];
 
 765   foreach my $price ( @{$prices} ) {
 
 766     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 767     next unless $sellprice > 0; # skip negative prices as well
 
 768     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 769                                pricegroup_id => $price->{pricegroup_id},
 
 772     $self->part->add_prices($p);
 
 776 sub parse_form_translations {
 
 778   # don't add empty translations
 
 779   my $translations = delete($::form->{translations}) || [];
 
 780   foreach my $translation ( @{$translations} ) {
 
 781     next unless $translation->{translation};
 
 782     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 783     $self->part->add_translations( $translation );
 
 787 sub parse_form_makemodels {
 
 791   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 792     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 795   $self->part->makemodels([]);
 
 798   my $makemodels = delete($::form->{makemodels}) || [];
 
 799   foreach my $makemodel ( @{$makemodels} ) {
 
 800     next unless $makemodel->{make};
 
 802     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 804     my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
 
 805     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 807                                      make       => $makemodel->{make},
 
 808                                      model      => $makemodel->{model} || '',
 
 809                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 810                                      sortorder  => $position,
 
 812     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 813       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 814       # don't change lastupdate
 
 815     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 816       # new makemodel, no lastcost entered, leave lastupdate empty
 
 817     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 818       # lastcost hasn't changed, use original lastupdate
 
 819       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 821       $mm->lastupdate(DateTime->now);
 
 823     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 824     $self->part->add_makemodels($mm);
 
 828 sub parse_form_customerprices {
 
 831   my $customerprices_map;
 
 832   if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
 
 833     $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
 
 836   $self->part->customerprices([]);
 
 839   my $customerprices = delete($::form->{customerprices}) || [];
 
 840   foreach my $customerprice ( @{$customerprices} ) {
 
 841     next unless $customerprice->{customer_id};
 
 843     my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
 
 845     my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
 
 846     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 848                                      customer_id          => $customerprice->{customer_id},
 
 849                                      customer_partnumber  => $customerprice->{customer_partnumber} || '',
 
 850                                      price                => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
 
 851                                      sortorder            => $position,
 
 853     if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
 
 854       # lastupdate isn't set, original price is 0 and new lastcost is 0
 
 855       # don't change lastupdate
 
 856     } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
 
 857       # new customerprice, no lastcost entered, leave lastupdate empty
 
 858     } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
 
 859       # price hasn't changed, use original lastupdate
 
 860       $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
 
 862       $cu->lastupdate(DateTime->now);
 
 864     $self->part->add_customerprices($cu);
 
 868 sub build_bin_select {
 
 869   select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 870     title_key => 'description',
 
 871     default   => $_[0]->bin->id,
 
 876 # get_set_inits for partpicker
 
 879   if ($::form->{no_paginate}) {
 
 880     $_[0]->models->disable_plugin('paginated');
 
 886 # get_set_inits for part controller
 
 890   # used by edit, save, delete and add
 
 892   if ( $::form->{part}{id} ) {
 
 893     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 895     die "part_type missing" unless $::form->{part}{part_type};
 
 896     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 902   return $self->part->orphaned;
 
 908   SL::Controller::Helper::GetModels->new(
 
 915       partnumber  => t8('Partnumber'),
 
 916       description  => t8('Description'),
 
 918     with_objects => [ qw(unit_obj classification) ],
 
 927 sub init_assortment_items {
 
 928   # this init is used while saving and whenever assortments change dynamically
 
 932   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 933   foreach my $assortment_item ( @{$assortment_items} ) {
 
 934     next unless $assortment_item->{parts_id};
 
 936     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 937     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 938                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 939                                           charge        => $assortment_item->{charge},
 
 940                                           unit          => $assortment_item->{unit} || $part->unit,
 
 941                                           position      => $position,
 
 949 sub init_makemodels {
 
 953   my @makemodel_array = ();
 
 954   my $makemodels = delete($::form->{makemodels}) || [];
 
 956   foreach my $makemodel ( @{$makemodels} ) {
 
 957     next unless $makemodel->{make};
 
 959     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 960                                     id        => $makemodel->{id},
 
 961                                     make      => $makemodel->{make},
 
 962                                     model     => $makemodel->{model} || '',
 
 963                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 964                                     sortorder => $position,
 
 965                                   ) or die "Can't create mm";
 
 966     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 967     push(@makemodel_array, $mm);
 
 969   return \@makemodel_array;
 
 972 sub init_customerprices {
 
 976   my @customerprice_array = ();
 
 977   my $customerprices = delete($::form->{customerprices}) || [];
 
 979   foreach my $customerprice ( @{$customerprices} ) {
 
 980     next unless $customerprice->{customer_id};
 
 982     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 983                                     id                  => $customerprice->{id},
 
 984                                     customer_partnumber => $customerprice->{customer_partnumber},
 
 985                                     customer_id         => $customerprice->{customer_id} || '',
 
 986                                     price               => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
 
 987                                     sortorder           => $position,
 
 988                                   ) or die "Can't create cu";
 
 989     # $cu->id($customerprice->{id}) if $customerprice->{id};
 
 990     push(@customerprice_array, $cu);
 
 992   return \@customerprice_array;
 
 995 sub init_assembly_items {
 
 999   my $assembly_items = delete($::form->{assembly_items}) || [];
 
1000   foreach my $assembly_item ( @{$assembly_items} ) {
 
1001     next unless $assembly_item->{parts_id};
 
1003     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
1004     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1005                                    bom         => $assembly_item->{bom},
 
1006                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
1007                                    position    => $position,
 
1014 sub init_all_warehouses {
 
1016   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
1019 sub init_all_languages {
 
1020   SL::DB::Manager::Language->get_all_sorted;
 
1023 sub init_all_partsgroups {
 
1025   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
1028 sub init_all_buchungsgruppen {
 
1030   if ( $self->part->orphaned ) {
 
1031     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
1033     return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
 
1037 sub init_shops_not_assigned {
 
1040   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
1041   if ( @used_shop_ids ) {
 
1042     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
1045     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
1049 sub init_all_units {
 
1051   if ( $self->part->orphaned ) {
 
1052     return SL::DB::Manager::Unit->get_all_sorted;
 
1054     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
1058 sub init_all_payment_terms {
 
1060   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
1063 sub init_all_price_factors {
 
1064   SL::DB::Manager::PriceFactor->get_all_sorted;
 
1067 sub init_all_pricegroups {
 
1068   SL::DB::Manager::Pricegroup->get_all_sorted;
 
1071 # model used to filter/display the parts in the multi-items dialog
 
1072 sub init_multi_items_models {
 
1073   SL::Controller::Helper::GetModels->new(
 
1074     controller     => $_[0],
 
1076     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
1077     disable_plugin => 'paginated',
 
1078     source         => $::form->{multi_items},
 
1084       partnumber  => t8('Partnumber'),
 
1085       description => t8('Description')}
 
1089 sub init_parts_classification_filter {
 
1090   return [] unless $::form->{parts_classification_type};
 
1092   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
1093   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
1095   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
1098 # simple checks to run on $::form before saving
 
1100 sub form_check_part_description_exists {
 
1103   return 1 if $::form->{part}{description};
 
1105   $self->js->flash('error', t8('Part Description missing!'))
 
1106            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
1107            ->focus('#part_description');
 
1111 sub form_check_assortment_items_exist {
 
1114   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1115   # skip item check for existing assortments that have been used
 
1116   return 1 if ($self->part->id and !$self->part->orphaned);
 
1118   # new or orphaned parts must have items in $::form->{assortment_items}
 
1119   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1120     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1121              ->focus('#add_assortment_item_name')
 
1122              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1128 sub form_check_assortment_items_unique {
 
1131   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1133   my %duplicate_elements;
 
1135   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1136     $duplicate_elements{$_}++ if $count{$_}++;
 
1139   if ( keys %duplicate_elements ) {
 
1140     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1141              ->flash('error', t8('There are duplicate assortment items'));
 
1147 sub form_check_assembly_items_exist {
 
1150   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1152   # skip item check for existing assembly that have been used
 
1153   return 1 if ($self->part->id and !$self->part->orphaned);
 
1155   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1156     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1157              ->focus('#add_assembly_item_name')
 
1158              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1164 sub form_check_partnumber_is_unique {
 
1167   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1168     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1170       $self->js->flash('error', t8('The partnumber already exists!'))
 
1171                ->focus('#part_description');
 
1178 # general checking functions
 
1181   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1187   $self->form_check_part_description_exists || return 0;
 
1188   $self->form_check_assortment_items_exist  || return 0;
 
1189   $self->form_check_assortment_items_unique || return 0;
 
1190   $self->form_check_assembly_items_exist    || return 0;
 
1191   $self->form_check_partnumber_is_unique    || return 0;
 
1196 sub check_has_valid_part_type {
 
1197   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1201 sub normalize_text_blocks {
 
1204   # check if feature is enabled (select normalize_part_descriptions from defaults)
 
1205   return unless ($::instance_conf->get_normalize_part_descriptions);
 
1208   foreach (qw(description)) {
 
1209     $self->part->{$_} =~ s/\s+$//s;
 
1210     $self->part->{$_} =~ s/^\s+//s;
 
1211     $self->part->{$_} =~ s/ {2,}/ /g;
 
1213   # html block (caveat: can be circumvented by using bold or italics)
 
1214   $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
 
1215   $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
 
1219 sub render_assortment_items_to_html {
 
1220   my ($self, $assortment_items, $number_of_items) = @_;
 
1222   my $position = $number_of_items + 1;
 
1224   foreach my $ai (@$assortment_items) {
 
1225     $html .= $self->p->render('part/_assortment_row',
 
1226                               PART     => $self->part,
 
1227                               orphaned => $self->orphaned,
 
1229                               listrow  => $position % 2 ? 1 : 0,
 
1230                               position => $position, # for legacy assemblies
 
1237 sub render_assembly_items_to_html {
 
1238   my ($self, $assembly_items, $number_of_items) = @_;
 
1240   my $position = $number_of_items + 1;
 
1242   foreach my $ai (@{$assembly_items}) {
 
1243     $html .= $self->p->render('part/_assembly_row',
 
1244                               PART     => $self->part,
 
1245                               orphaned => $self->orphaned,
 
1247                               listrow  => $position % 2 ? 1 : 0,
 
1248                               position => $position, # for legacy assemblies
 
1255 sub parse_add_items_to_objects {
 
1256   my ($self, %params) = @_;
 
1257   my $part_type = $params{part_type};
 
1258   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1259   my $position = $params{position} || 1;
 
1261   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1264   foreach my $item ( @add_items ) {
 
1265     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1267     if ( $part_type eq 'assortment' ) {
 
1268        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1269                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1270                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1271                                          position      => $position,
 
1272                                         ) or die "Can't create AssortmentItem from item";
 
1273     } elsif ( $part_type eq 'assembly' ) {
 
1274       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1275                                  # id          => $self->assembly->id, # will be set on save
 
1276                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1277                                  bom         => 0, # default when adding: no bom
 
1278                                  position    => $position,
 
1281       die "part_type must be assortment or assembly";
 
1283     push(@item_objects, $ai);
 
1287   return \@item_objects;
 
1290 sub _setup_form_action_bar {
 
1293   my $may_edit           = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1294   my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
 
1296   for my $bar ($::request->layout->get('actionbar')) {
 
1301           call      => [ 'kivi.Part.save' ],
 
1302           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1306           call     => [ 'kivi.Part.use_as_new' ],
 
1307           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1308                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1311       ], # end of combobox "Save"
 
1315         call     => [ 'kivi.Part.delete' ],
 
1316         confirm  => t8('Do you really want to delete this object?'),
 
1317         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1318                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1319                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1320                   : $used_in_pricerules    ? t8('This object is used in price rules.')
 
1328         call     => [ 'kivi.Part.open_history_popup' ],
 
1329         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1330                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1345 SL::Controller::Part - Part CRUD controller
 
1349 Controller for adding/editing/saving/deleting parts.
 
1351 All the relations are loaded at once and saving the part, adding a history
 
1352 entry and saving CVars happens inside one transaction.  When saving the old
 
1353 relations are deleted and written as new to the database.
 
1355 Relations for parts:
 
1363 =item assembly items
 
1365 =item assortment items
 
1373 There are 4 different part types:
 
1379 The "default" part type.
 
1381 inventory_accno_id is set.
 
1385 Services can't be stocked.
 
1387 inventory_accno_id isn't set.
 
1391 Assemblies consist of other parts, services, assemblies or assortments. They
 
1392 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1393 have to make them, which reduces the stock by its respective components. Once
 
1394 an assembly item has been created there is currently no way to "disassemble" it
 
1395 again. An assembly item can appear several times in one assembly. An assmbly is
 
1396 sold as one item with a defined sellprice and lastcost. If the component prices
 
1397 change the assortment price remains the same. The assembly items may be printed
 
1398 in a record if the item's "bom" is set.
 
1402 Similar to assembly, but each assortment item may only appear once per
 
1403 assortment. When selling an assortment the assortment items are added to the
 
1404 record together with the assortment, which is added with sellprice 0.
 
1406 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1407 determined by the sum of the current assortment item prices when the assortment
 
1408 is added to a record. This also means that price rules and customer discounts
 
1409 will be applied to the assortment items.
 
1411 Once the assortment items have been added they may be modified or deleted, just
 
1412 as if they had been added manually, the individual assortment items aren't
 
1413 linked to the assortment or the other assortment items in any way.
 
1421 =item C<action_add_part>
 
1423 =item C<action_add_service>
 
1425 =item C<action_add_assembly>
 
1427 =item C<action_add_assortment>
 
1429 =item C<action_add PART_TYPE>
 
1431 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1432 parameter part_type as an action. Example:
 
1434   controller.pl?action=Part/add&part_type=service
 
1436 =item C<action_add_from_record>
 
1438 When adding new items to records they can be created on the fly if the entered
 
1439 partnumber or description doesn't exist yet. After being asked what part type
 
1440 the new item should have the user is redirected to the correct edit page.
 
1442 Depending on whether the item was added from a sales or a purchase record, only
 
1443 the relevant part classifications should be selectable for new item, so this
 
1444 parameter is passed on via a hidden parts_classification_type in the new_item
 
1447 =item C<action_save>
 
1449 Saves the current part and then reloads the edit page for the part.
 
1451 =item C<action_use_as_new>
 
1453 Takes the information from the current part, plus any modifications made on the
 
1454 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1455 set empty, so a new partnumber from the number range will be used if the user
 
1456 doesn't enter one manually.
 
1458 Unsaved changes to the original part aren't updated.
 
1460 The part type cannot be changed in this way.
 
1462 =item C<action_delete>
 
1464 Deletes the current part and then redirects to the main page, there is no
 
1467 The delete button only appears if the part is 'orphaned', according to
 
1468 SL::DB::Part orphaned.
 
1470 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1471 the inventory, or is part of an assembly or assortment.
 
1473 If the part is deleted its relations prices, makdemodel, assembly,
 
1474 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1476 Before this controller items that appeared in inventory didn't count as
 
1477 orphaned and could be deleted and the inventory entries were also deleted, this
 
1478 "feature" hasn't been implemented.
 
1480 =item C<action_edit part.id>
 
1482 Load and display a part for editing.
 
1484   controller.pl?action=Part/edit&part.id=12345
 
1486 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1490 =head1 BUTTON ACTIONS
 
1496 Opens a popup displaying all the history entries. Once a new history controller
 
1497 is written the button could link there instead, with the part already selected.
 
1505 =item C<action_update_item_totals>
 
1507 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1508 amount of an item changes. The sum of all sellprices and lastcosts is
 
1509 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1511 =item C<action_add_assortment_item>
 
1513 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1515 If the item already exists in the assortment the item isn't added and a Flash
 
1518 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1519 after adding each new item, add the new object to the item objects that were
 
1520 already parsed, calculate totals via a dummy part then update the row and the
 
1523 =item C<action_add_assembly_item>
 
1525 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1527 If the item already exists in the assembly a flash info is generated, but the
 
1530 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1531 after adding each new item, add the new object to the item objects that were
 
1532 already parsed, calculate totals via a dummy part then update the row and the
 
1535 =item C<action_add_multi_assortment_items>
 
1537 Parses the items to be added from the form generated by the multi input and
 
1538 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1539 assortment items are renumbered and the sums recalculated via
 
1540 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1542 =item C<action_add_multi_assembly_items>
 
1544 Parses the items to be added from the form generated by the multi input and
 
1545 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1546 assembly items are renumbered and the sums recalculated via
 
1547 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1549 =item C<action_show_multi_items_dialog>
 
1551 =item C<action_multi_items_update_result>
 
1553 =item C<action_add_makemodel_row>
 
1555 Add a new makemodel row with the vendor that was selected via the vendor
 
1558 Checks the already existing makemodels and warns if a row with that vendor
 
1559 already exists. Currently it is possible to have duplicate vendor rows.
 
1561 =item C<action_reorder_items>
 
1563 Sorts the item table for assembly or assortment items.
 
1565 =item C<action_warehouse_changed>
 
1569 =head1 ACTIONS part picker
 
1573 =item C<action_ajax_autocomplete>
 
1575 =item C<action_test_page>
 
1577 =item C<action_part_picker_search>
 
1579 =item C<action_part_picker_result>
 
1581 =item C<action_show>
 
1591 Calls some simple checks that test the submitted $::form for obvious errors.
 
1592 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1594 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1595 some cases extra actions are taken, e.g. if the part description is missing the
 
1596 basic data tab is selected and the description input field is focussed.
 
1602 =item C<form_check_part_description_exists>
 
1604 =item C<form_check_assortment_items_exist>
 
1606 =item C<form_check_assortment_items_unique>
 
1608 =item C<form_check_assembly_items_exist>
 
1610 =item C<form_check_partnumber_is_unique>
 
1614 =head1 HELPER FUNCTIONS
 
1620 When submitting the form for saving, parses the transmitted form. Expects the
 
1624  $::form->{makemodels}
 
1625  $::form->{translations}
 
1627  $::form->{assemblies}
 
1628  $::form->{assortments}
 
1630 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1632 =item C<recalc_item_totals %params>
 
1634 Helper function for calculating the total lastcost and sellprice for assemblies
 
1635 or assortments according to their items, which are parsed from the current
 
1638 Is called whenever the qty of an item is changed or items are deleted.
 
1642 * part_type : 'assortment' or 'assembly' (mandatory)
 
1644 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1646 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1648 Doesn't work for recursive items.
 
1652 =head1 GET SET INITS
 
1654 There are get_set_inits for
 
1662 which parse $::form and automatically create an array of objects.
 
1664 These inits are used during saving and each time a new element is added.
 
1668 =item C<init_makemodels>
 
1670 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1671 $self->part->makemodels, ready to be saved.
 
1673 Used for saving parts and adding new makemodel rows.
 
1675 =item C<parse_add_items_to_objects PART_TYPE>
 
1677 Parses the resulting form from either the part-picker submit or the multi-item
 
1678 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1679 can be rendered via C<render_assortment_items_to_html> or
 
1680 C<render_assembly_items_to_html>.
 
1682 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1683 Optional param: position (used for numbering and listrow class)
 
1685 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1687 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1688 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1689 assortment items are added.
 
1691 =item C<parse_form_makemodels>
 
1693 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1694 remembers when the lastcost for that vendor changed the last time.
 
1696 So the original values are cloned and remembered, so we can compare if lastcost
 
1697 was changed in $::form, and keep or update lastupdate.
 
1699 lastcost isn't updated until the first time it was saved with a value, until
 
1702 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1703 makemodel entries exist or not.
 
1705 We still need init_makemodels for when we open the part for editing.
 
1715 It should be possible to jump to the edit page in a specific tab
 
1719 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1720 back to the order again afterwards.
 
1724 Support units when adding assembly items or assortment items. Currently the
 
1725 default unit of the item is always used.
 
1729 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1730 consists of other assemblies.
 
1736 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>