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 stock_amounts journal) ],
 
  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);
 
 233   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 234     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 236   my %title_hash = ( part       => t8('Edit Part'),
 
 237                      assembly   => t8('Edit Assembly'),
 
 238                      service    => t8('Edit Service'),
 
 239                      assortment => t8('Edit Assortment'),
 
 242   $self->part->prices([])       unless $self->part->prices;
 
 243   $self->part->translations([]) unless $self->part->translations;
 
 247     title             => $title_hash{$self->part->part_type},
 
 250     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 251     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 252     oldpartnumber     => $::form->{oldpartnumber},
 
 253     old_id            => $::form->{old_id},
 
 261   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 262   $_[0]->render('part/history', { layout => 0 },
 
 263                                   history_entries => $history_entries);
 
 266 sub action_inventory {
 
 269   $::auth->assert('warehouse_contents');
 
 271   $self->stock_amounts($self->part->get_simple_stock_sql);
 
 272   $self->journal($self->part->get_mini_journal);
 
 274   $_[0]->render('part/_inventory_data', { layout => 0 });
 
 277 sub action_update_item_totals {
 
 280   my $part_type = $::form->{part_type};
 
 281   die unless $part_type =~ /^(assortment|assembly)$/;
 
 283   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 284   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 286   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 289     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 290     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 291     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 292     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 293     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 294     ->no_flash_clear->render();
 
 297 sub action_add_multi_assortment_items {
 
 300   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 301   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 303   $self->js->run('kivi.Part.close_picker_dialogs')
 
 304            ->append('#assortment_rows', $html)
 
 305            ->run('kivi.Part.renumber_positions')
 
 306            ->run('kivi.Part.assortment_recalc')
 
 310 sub action_add_multi_assembly_items {
 
 313   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 315   foreach my $item (@{$item_objects}) {
 
 316     my $errstr = validate_assembly($item->part,$self->part);
 
 317     $self->js->flash('error',$errstr) if     $errstr;
 
 318     push (@checked_objects,$item)     unless $errstr;
 
 321   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 323   $self->js->run('kivi.Part.close_picker_dialogs')
 
 324            ->append('#assembly_rows', $html)
 
 325            ->run('kivi.Part.renumber_positions')
 
 326            ->run('kivi.Part.assembly_recalc')
 
 330 sub action_add_assortment_item {
 
 331   my ($self, %params) = @_;
 
 333   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 335   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 337   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 338   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 339     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 342   my $number_of_items = scalar @{$self->assortment_items};
 
 343   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 344   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 346   push(@{$self->assortment_items}, @{$item_objects});
 
 347   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 348   $part->assortment_items(@{$self->assortment_items});
 
 349   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 350   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 351   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 354     ->append('#assortment_rows'        , $html)  # append in tbody
 
 355     ->val('.add_assortment_item_input' , '')
 
 356     ->run('kivi.Part.focus_last_assortment_input')
 
 357     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 358     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 359     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 360     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 361     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 365 sub action_add_assembly_item {
 
 368   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 370   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 372   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 374   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 375   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 376     $duplicate_warning++;
 
 379   my $number_of_items = scalar @{$self->assembly_items};
 
 380   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 382     foreach my $item (@{$item_objects}) {
 
 383       my $errstr = validate_assembly($item->part,$self->part);
 
 384       return $self->js->flash('error',$errstr)->render if $errstr;
 
 389   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 391   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 393   push(@{$self->assembly_items}, @{$item_objects});
 
 394   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 395   $part->assemblies(@{$self->assembly_items});
 
 396   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 397   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 398   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 401     ->append('#assembly_rows', $html)  # append in tbody
 
 402     ->val('.add_assembly_item_input' , '')
 
 403     ->run('kivi.Part.focus_last_assembly_input')
 
 404     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 405     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 406     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 407     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 408     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 412 sub action_show_multi_items_dialog {
 
 415   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 416   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 417   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 419   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 420                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
 
 421                 search_term     => $search_term
 
 425 sub action_multi_items_update_result {
 
 426   my $max_count = $::form->{limit};
 
 428   my $count = $_[0]->multi_items_models->count;
 
 431     my $text = escape($::locale->text('No results.'));
 
 432     $_[0]->render($text, { layout => 0 });
 
 433   } elsif ($max_count && $count > $max_count) {
 
 434     my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 435     $_[0]->render($text, { layout => 0 });
 
 437     my $multi_items = $_[0]->multi_items_models->get;
 
 438     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 439                   multi_items => $multi_items);
 
 443 sub action_add_makemodel_row {
 
 446   my $vendor_id = $::form->{add_makemodel};
 
 448   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 449     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 451   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 452     $self->js->flash('info', t8("This vendor has already been added."));
 
 455   my $position = scalar @{$self->makemodels} + 1;
 
 457   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 461                                   sortorder    => $position,
 
 462                                  ) or die "Can't create MakeModel object";
 
 464   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 466                                      listrow   => $position % 2 ? 0 : 1,
 
 469   # after selection focus on the model field in the row that was just added
 
 471     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 472     ->val('.add_makemodel_input', '')
 
 473     ->run('kivi.Part.focus_last_makemodel_input')
 
 477 sub action_add_customerprice_row {
 
 480   my $customer_id = $::form->{add_customerprice};
 
 482   my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
 
 483     or return $self->js->error(t8("No customer selected or found!"))->render;
 
 485   if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
 
 486     $self->js->flash('info', t8("This customer has already been added."));
 
 489   my $position = scalar @{ $self->customerprices } + 1;
 
 491   my $cu = SL::DB::PartCustomerPrice->new(
 
 492                       customer_id         => $customer->id,
 
 493                       customer_partnumber => '',
 
 495                       sortorder           => $position,
 
 496   ) or die "Can't create Customerprice object";
 
 498   my $row_as_html = $self->p->render(
 
 499                                      'part/_customerprice_row',
 
 500                                       customerprice => $cu,
 
 501                                       listrow       => $position % 2 ? 0
 
 505   $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
 
 506            ->val('.add_customerprice_input', '')
 
 507            ->run('kivi.Part.focus_last_customerprice_input')->render;
 
 510 sub action_reorder_items {
 
 513   my $part_type = $::form->{part_type};
 
 516     partnumber  => sub { $_[0]->part->partnumber },
 
 517     description => sub { $_[0]->part->description },
 
 518     qty         => sub { $_[0]->qty },
 
 519     sellprice   => sub { $_[0]->part->sellprice },
 
 520     lastcost    => sub { $_[0]->part->lastcost },
 
 521     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 524   my $method = $sort_keys{$::form->{order_by}};
 
 527   if ($part_type eq 'assortment') {
 
 528     @items = @{ $self->assortment_items };
 
 530     @items = @{ $self->assembly_items };
 
 533   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 534   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 535     if ($::form->{sort_dir}) {
 
 536       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 538       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 541     if ($::form->{sort_dir}) {
 
 542       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 544       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 548   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 551 sub action_warehouse_changed {
 
 554   if ($::form->{warehouse_id} ) {
 
 555     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 556     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 558     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 559       $self->bin($self->warehouse->bins_sorted->[0]);
 
 561         ->html('#bin', $self->build_bin_select)
 
 562         ->focus('#part_bin_id');
 
 563       return $self->js->render;
 
 567   # no warehouse was selected, empty the bin field and reset the id
 
 569        ->val('#part_bin_id', undef)
 
 572   return $self->js->render;
 
 575 sub action_ajax_autocomplete {
 
 576   my ($self, %params) = @_;
 
 578   # if someone types something, and hits enter, assume he entered the full name.
 
 579   # if something matches, treat that as sole match
 
 580   # since we need a second get models instance with different filters for that,
 
 581   # we only modify the original filter temporarily in place
 
 582   if ($::form->{prefer_exact}) {
 
 583     local $::form->{filter}{'all::ilike'}                          = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 584     local $::form->{filter}{'all_with_makemodel::ilike'}           = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
 
 585     local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
 
 587     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 590       paginated    => { per_page => 2 },
 
 591       with_objects => [ qw(unit_obj classification) ],
 
 594     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 595       $self->parts($exact_matches);
 
 601      value       => $_->displayable_name,
 
 602      label       => $_->displayable_name,
 
 604      partnumber  => $_->partnumber,
 
 605      description => $_->description,
 
 607      part_type   => $_->part_type,
 
 609      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 611   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 613   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 616 sub action_test_page {
 
 617   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 620 sub action_part_picker_search {
 
 623   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 624   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 625   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 627   $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
 
 630 sub action_part_picker_result {
 
 631   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 637   if ($::request->type eq 'json') {
 
 642       $part_hash          = $self->part->as_tree;
 
 643       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 646     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 651 sub validate_add_items {
 
 652   scalar @{$::form->{add_items}};
 
 655 sub prepare_assortment_render_vars {
 
 658   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 659                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 660                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 662   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 667 sub prepare_assembly_render_vars {
 
 670   croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
 
 672   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 673                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 674                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 676   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 684   check_has_valid_part_type($self->part->part_type);
 
 686   $self->_set_javascript;
 
 687   $self->_setup_form_action_bar;
 
 689   my %title_hash = ( part       => t8('Add Part'),
 
 690                      assembly   => t8('Add Assembly'),
 
 691                      service    => t8('Add Service'),
 
 692                      assortment => t8('Add Assortment'),
 
 697     title => $title_hash{$self->part->part_type},
 
 702 sub _set_javascript {
 
 704   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
 
 705   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 708 sub recalc_item_totals {
 
 709   my ($self, %params) = @_;
 
 711   if ( $params{part_type} eq 'assortment' ) {
 
 712     return 0 unless scalar @{$self->assortment_items};
 
 713   } elsif ( $params{part_type} eq 'assembly' ) {
 
 714     return 0 unless scalar @{$self->assembly_items};
 
 716     carp "can only calculate sum for assortments and assemblies";
 
 719   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 720   if ( $part->is_assortment ) {
 
 721     $part->assortment_items( @{$self->assortment_items} );
 
 722     if ( $params{price_type} eq 'lastcost' ) {
 
 723       return $part->items_lastcost_sum;
 
 725       if ( $params{pricegroup_id} ) {
 
 726         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 728         return $part->items_sellprice_sum;
 
 731   } elsif ( $part->is_assembly ) {
 
 732     $part->assemblies( @{$self->assembly_items} );
 
 733     if ( $params{price_type} eq 'lastcost' ) {
 
 734       return $part->items_lastcost_sum;
 
 736       return $part->items_sellprice_sum;
 
 741 sub check_part_not_modified {
 
 744   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 751   my $is_new = !$self->part->id;
 
 753   my $params = delete($::form->{part}) || { };
 
 755   delete $params->{id};
 
 756   $self->part->assign_attributes(%{ $params});
 
 757   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 759   $self->normalize_text_blocks;
 
 761   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 762   # will be the case for used assortments when saving, or when a used assortment
 
 764   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 765     $self->part->assortment_items([]);
 
 766     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 769   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 770     $self->part->assemblies([]); # completely rewrite assortments each time
 
 771     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 774   $self->part->translations([]);
 
 775   $self->parse_form_translations;
 
 777   $self->part->prices([]);
 
 778   $self->parse_form_prices;
 
 780   $self->parse_form_customerprices;
 
 781   $self->parse_form_makemodels;
 
 784 sub parse_form_prices {
 
 786   # only save prices > 0
 
 787   my $prices = delete($::form->{prices}) || [];
 
 788   foreach my $price ( @{$prices} ) {
 
 789     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 790     next unless $sellprice > 0; # skip negative prices as well
 
 791     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 792                                pricegroup_id => $price->{pricegroup_id},
 
 795     $self->part->add_prices($p);
 
 799 sub parse_form_translations {
 
 801   # don't add empty translations
 
 802   my $translations = delete($::form->{translations}) || [];
 
 803   foreach my $translation ( @{$translations} ) {
 
 804     next unless $translation->{translation};
 
 805     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 806     $self->part->add_translations( $translation );
 
 810 sub parse_form_makemodels {
 
 814   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 815     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 818   $self->part->makemodels([]);
 
 821   my $makemodels = delete($::form->{makemodels}) || [];
 
 822   foreach my $makemodel ( @{$makemodels} ) {
 
 823     next unless $makemodel->{make};
 
 825     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 827     my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
 
 828     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 830                                      make       => $makemodel->{make},
 
 831                                      model      => $makemodel->{model} || '',
 
 832                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 833                                      sortorder  => $position,
 
 835     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 836       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 837       # don't change lastupdate
 
 838     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 839       # new makemodel, no lastcost entered, leave lastupdate empty
 
 840     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 841       # lastcost hasn't changed, use original lastupdate
 
 842       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 844       $mm->lastupdate(DateTime->now);
 
 846     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 847     $self->part->add_makemodels($mm);
 
 851 sub parse_form_customerprices {
 
 854   my $customerprices_map;
 
 855   if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
 
 856     $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
 
 859   $self->part->customerprices([]);
 
 862   my $customerprices = delete($::form->{customerprices}) || [];
 
 863   foreach my $customerprice ( @{$customerprices} ) {
 
 864     next unless $customerprice->{customer_id};
 
 866     my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
 
 868     my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
 
 869     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 871                                      customer_id          => $customerprice->{customer_id},
 
 872                                      customer_partnumber  => $customerprice->{customer_partnumber} || '',
 
 873                                      price                => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
 
 874                                      sortorder            => $position,
 
 876     if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
 
 877       # lastupdate isn't set, original price is 0 and new lastcost is 0
 
 878       # don't change lastupdate
 
 879     } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
 
 880       # new customerprice, no lastcost entered, leave lastupdate empty
 
 881     } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
 
 882       # price hasn't changed, use original lastupdate
 
 883       $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
 
 885       $cu->lastupdate(DateTime->now);
 
 887     $self->part->add_customerprices($cu);
 
 891 sub build_bin_select {
 
 892   select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
 
 893     title_key => 'description',
 
 894     default   => $_[0]->bin->id,
 
 899 # get_set_inits for partpicker
 
 902   if ($::form->{no_paginate}) {
 
 903     $_[0]->models->disable_plugin('paginated');
 
 909 # get_set_inits for part controller
 
 913   # used by edit, save, delete and add
 
 915   if ( $::form->{part}{id} ) {
 
 916     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 917   } elsif ( $::form->{id} ) {
 
 918     return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
 
 920     die "part_type missing" unless $::form->{part}{part_type};
 
 921     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 927   return $self->part->orphaned;
 
 933   SL::Controller::Helper::GetModels->new(
 
 940       partnumber  => t8('Partnumber'),
 
 941       description  => t8('Description'),
 
 943     with_objects => [ qw(unit_obj classification) ],
 
 952 sub init_assortment_items {
 
 953   # this init is used while saving and whenever assortments change dynamically
 
 957   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 958   foreach my $assortment_item ( @{$assortment_items} ) {
 
 959     next unless $assortment_item->{parts_id};
 
 961     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 962     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 963                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 964                                           charge        => $assortment_item->{charge},
 
 965                                           unit          => $assortment_item->{unit} || $part->unit,
 
 966                                           position      => $position,
 
 974 sub init_makemodels {
 
 978   my @makemodel_array = ();
 
 979   my $makemodels = delete($::form->{makemodels}) || [];
 
 981   foreach my $makemodel ( @{$makemodels} ) {
 
 982     next unless $makemodel->{make};
 
 984     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 985                                     id        => $makemodel->{id},
 
 986                                     make      => $makemodel->{make},
 
 987                                     model     => $makemodel->{model} || '',
 
 988                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 989                                     sortorder => $position,
 
 990                                   ) or die "Can't create mm";
 
 991     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 992     push(@makemodel_array, $mm);
 
 994   return \@makemodel_array;
 
 997 sub init_customerprices {
 
1001   my @customerprice_array = ();
 
1002   my $customerprices = delete($::form->{customerprices}) || [];
 
1004   foreach my $customerprice ( @{$customerprices} ) {
 
1005     next unless $customerprice->{customer_id};
 
1007     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
1008                                     id                  => $customerprice->{id},
 
1009                                     customer_partnumber => $customerprice->{customer_partnumber},
 
1010                                     customer_id         => $customerprice->{customer_id} || '',
 
1011                                     price               => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
 
1012                                     sortorder           => $position,
 
1013                                   ) or die "Can't create cu";
 
1014     # $cu->id($customerprice->{id}) if $customerprice->{id};
 
1015     push(@customerprice_array, $cu);
 
1017   return \@customerprice_array;
 
1020 sub init_assembly_items {
 
1024   my $assembly_items = delete($::form->{assembly_items}) || [];
 
1025   foreach my $assembly_item ( @{$assembly_items} ) {
 
1026     next unless $assembly_item->{parts_id};
 
1028     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
1029     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1030                                    bom         => $assembly_item->{bom},
 
1031                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
1032                                    position    => $position,
 
1039 sub init_all_warehouses {
 
1041   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
1044 sub init_all_languages {
 
1045   SL::DB::Manager::Language->get_all_sorted;
 
1048 sub init_all_partsgroups {
 
1050   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
1053 sub init_all_buchungsgruppen {
 
1055   if ( $self->part->orphaned ) {
 
1056     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
1058     return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
 
1062 sub init_shops_not_assigned {
 
1065   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
1066   if ( @used_shop_ids ) {
 
1067     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
1070     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
1074 sub init_all_units {
 
1076   if ( $self->part->orphaned ) {
 
1077     return SL::DB::Manager::Unit->get_all_sorted;
 
1079     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
1083 sub init_all_payment_terms {
 
1085   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
1088 sub init_all_price_factors {
 
1089   SL::DB::Manager::PriceFactor->get_all_sorted;
 
1092 sub init_all_pricegroups {
 
1093   SL::DB::Manager::Pricegroup->get_all_sorted;
 
1096 # model used to filter/display the parts in the multi-items dialog
 
1097 sub init_multi_items_models {
 
1098   SL::Controller::Helper::GetModels->new(
 
1099     controller     => $_[0],
 
1101     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
1102     disable_plugin => 'paginated',
 
1103     source         => $::form->{multi_items},
 
1109       partnumber  => t8('Partnumber'),
 
1110       description => t8('Description')}
 
1114 sub init_parts_classification_filter {
 
1115   return [] unless $::form->{parts_classification_type};
 
1117   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
1118   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
1120   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
1123 # simple checks to run on $::form before saving
 
1125 sub form_check_part_description_exists {
 
1128   return 1 if $::form->{part}{description};
 
1130   $self->js->flash('error', t8('Part Description missing!'))
 
1131            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
1132            ->focus('#part_description');
 
1136 sub form_check_assortment_items_exist {
 
1139   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1140   # skip item check for existing assortments that have been used
 
1141   return 1 if ($self->part->id and !$self->part->orphaned);
 
1143   # new or orphaned parts must have items in $::form->{assortment_items}
 
1144   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1145     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1146              ->focus('#add_assortment_item_name')
 
1147              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1153 sub form_check_assortment_items_unique {
 
1156   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1158   my %duplicate_elements;
 
1160   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1161     $duplicate_elements{$_}++ if $count{$_}++;
 
1164   if ( keys %duplicate_elements ) {
 
1165     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1166              ->flash('error', t8('There are duplicate assortment items'));
 
1172 sub form_check_assembly_items_exist {
 
1175   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1177   # skip item check for existing assembly that have been used
 
1178   return 1 if ($self->part->id and !$self->part->orphaned);
 
1180   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1181     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1182              ->focus('#add_assembly_item_name')
 
1183              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1189 sub form_check_partnumber_is_unique {
 
1192   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1193     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1195       $self->js->flash('error', t8('The partnumber already exists!'))
 
1196                ->focus('#part_description');
 
1203 # general checking functions
 
1206   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1212   $self->form_check_part_description_exists || return 0;
 
1213   $self->form_check_assortment_items_exist  || return 0;
 
1214   $self->form_check_assortment_items_unique || return 0;
 
1215   $self->form_check_assembly_items_exist    || return 0;
 
1216   $self->form_check_partnumber_is_unique    || return 0;
 
1221 sub check_has_valid_part_type {
 
1222   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1226 sub normalize_text_blocks {
 
1229   # check if feature is enabled (select normalize_part_descriptions from defaults)
 
1230   return unless ($::instance_conf->get_normalize_part_descriptions);
 
1233   foreach (qw(description)) {
 
1234     $self->part->{$_} =~ s/\s+$//s;
 
1235     $self->part->{$_} =~ s/^\s+//s;
 
1236     $self->part->{$_} =~ s/ {2,}/ /g;
 
1238   # html block (caveat: can be circumvented by using bold or italics)
 
1239   $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
 
1240   $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
 
1244 sub render_assortment_items_to_html {
 
1245   my ($self, $assortment_items, $number_of_items) = @_;
 
1247   my $position = $number_of_items + 1;
 
1249   foreach my $ai (@$assortment_items) {
 
1250     $html .= $self->p->render('part/_assortment_row',
 
1251                               PART     => $self->part,
 
1252                               orphaned => $self->orphaned,
 
1254                               listrow  => $position % 2 ? 1 : 0,
 
1255                               position => $position, # for legacy assemblies
 
1262 sub render_assembly_items_to_html {
 
1263   my ($self, $assembly_items, $number_of_items) = @_;
 
1265   my $position = $number_of_items + 1;
 
1267   foreach my $ai (@{$assembly_items}) {
 
1268     $html .= $self->p->render('part/_assembly_row',
 
1269                               PART     => $self->part,
 
1270                               orphaned => $self->orphaned,
 
1272                               listrow  => $position % 2 ? 1 : 0,
 
1273                               position => $position, # for legacy assemblies
 
1280 sub parse_add_items_to_objects {
 
1281   my ($self, %params) = @_;
 
1282   my $part_type = $params{part_type};
 
1283   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1284   my $position = $params{position} || 1;
 
1286   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1289   foreach my $item ( @add_items ) {
 
1290     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1292     if ( $part_type eq 'assortment' ) {
 
1293        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1294                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1295                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1296                                          position      => $position,
 
1297                                         ) or die "Can't create AssortmentItem from item";
 
1298     } elsif ( $part_type eq 'assembly' ) {
 
1299       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1300                                  # id          => $self->assembly->id, # will be set on save
 
1301                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1302                                  bom         => 0, # default when adding: no bom
 
1303                                  position    => $position,
 
1306       die "part_type must be assortment or assembly";
 
1308     push(@item_objects, $ai);
 
1312   return \@item_objects;
 
1315 sub _setup_form_action_bar {
 
1318   my $may_edit           = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1319   my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
 
1321   for my $bar ($::request->layout->get('actionbar')) {
 
1326           call      => [ 'kivi.Part.save' ],
 
1327           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1331           call     => [ 'kivi.Part.use_as_new' ],
 
1332           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1333                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1336       ], # end of combobox "Save"
 
1340         call     => [ 'kivi.Part.delete' ],
 
1341         confirm  => t8('Do you really want to delete this object?'),
 
1342         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1343                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1344                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1345                   : $used_in_pricerules    ? t8('This object is used in price rules.')
 
1353         call     => [ 'kivi.Part.open_history_popup' ],
 
1354         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1355                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1370 SL::Controller::Part - Part CRUD controller
 
1374 Controller for adding/editing/saving/deleting parts.
 
1376 All the relations are loaded at once and saving the part, adding a history
 
1377 entry and saving CVars happens inside one transaction.  When saving the old
 
1378 relations are deleted and written as new to the database.
 
1380 Relations for parts:
 
1388 =item assembly items
 
1390 =item assortment items
 
1398 There are 4 different part types:
 
1404 The "default" part type.
 
1406 inventory_accno_id is set.
 
1410 Services can't be stocked.
 
1412 inventory_accno_id isn't set.
 
1416 Assemblies consist of other parts, services, assemblies or assortments. They
 
1417 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1418 have to make them, which reduces the stock by its respective components. Once
 
1419 an assembly item has been created there is currently no way to "disassemble" it
 
1420 again. An assembly item can appear several times in one assembly. An assmbly is
 
1421 sold as one item with a defined sellprice and lastcost. If the component prices
 
1422 change the assortment price remains the same. The assembly items may be printed
 
1423 in a record if the item's "bom" is set.
 
1427 Similar to assembly, but each assortment item may only appear once per
 
1428 assortment. When selling an assortment the assortment items are added to the
 
1429 record together with the assortment, which is added with sellprice 0.
 
1431 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1432 determined by the sum of the current assortment item prices when the assortment
 
1433 is added to a record. This also means that price rules and customer discounts
 
1434 will be applied to the assortment items.
 
1436 Once the assortment items have been added they may be modified or deleted, just
 
1437 as if they had been added manually, the individual assortment items aren't
 
1438 linked to the assortment or the other assortment items in any way.
 
1446 =item C<action_add_part>
 
1448 =item C<action_add_service>
 
1450 =item C<action_add_assembly>
 
1452 =item C<action_add_assortment>
 
1454 =item C<action_add PART_TYPE>
 
1456 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1457 parameter part_type as an action. Example:
 
1459   controller.pl?action=Part/add&part_type=service
 
1461 =item C<action_add_from_record>
 
1463 When adding new items to records they can be created on the fly if the entered
 
1464 partnumber or description doesn't exist yet. After being asked what part type
 
1465 the new item should have the user is redirected to the correct edit page.
 
1467 Depending on whether the item was added from a sales or a purchase record, only
 
1468 the relevant part classifications should be selectable for new item, so this
 
1469 parameter is passed on via a hidden parts_classification_type in the new_item
 
1472 =item C<action_save>
 
1474 Saves the current part and then reloads the edit page for the part.
 
1476 =item C<action_use_as_new>
 
1478 Takes the information from the current part, plus any modifications made on the
 
1479 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1480 set empty, so a new partnumber from the number range will be used if the user
 
1481 doesn't enter one manually.
 
1483 Unsaved changes to the original part aren't updated.
 
1485 The part type cannot be changed in this way.
 
1487 =item C<action_delete>
 
1489 Deletes the current part and then redirects to the main page, there is no
 
1492 The delete button only appears if the part is 'orphaned', according to
 
1493 SL::DB::Part orphaned.
 
1495 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1496 the inventory, or is part of an assembly or assortment.
 
1498 If the part is deleted its relations prices, makdemodel, assembly,
 
1499 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1501 Before this controller items that appeared in inventory didn't count as
 
1502 orphaned and could be deleted and the inventory entries were also deleted, this
 
1503 "feature" hasn't been implemented.
 
1505 =item C<action_edit part.id>
 
1507 Load and display a part for editing.
 
1509   controller.pl?action=Part/edit&part.id=12345
 
1511 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1515 =head1 BUTTON ACTIONS
 
1521 Opens a popup displaying all the history entries. Once a new history controller
 
1522 is written the button could link there instead, with the part already selected.
 
1530 =item C<action_update_item_totals>
 
1532 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1533 amount of an item changes. The sum of all sellprices and lastcosts is
 
1534 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1536 =item C<action_add_assortment_item>
 
1538 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1540 If the item already exists in the assortment the item isn't added and a Flash
 
1543 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1544 after adding each new item, add the new object to the item objects that were
 
1545 already parsed, calculate totals via a dummy part then update the row and the
 
1548 =item C<action_add_assembly_item>
 
1550 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1552 If the item already exists in the assembly a flash info is generated, but the
 
1555 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1556 after adding each new item, add the new object to the item objects that were
 
1557 already parsed, calculate totals via a dummy part then update the row and the
 
1560 =item C<action_add_multi_assortment_items>
 
1562 Parses the items to be added from the form generated by the multi input and
 
1563 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1564 assortment items are renumbered and the sums recalculated via
 
1565 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1567 =item C<action_add_multi_assembly_items>
 
1569 Parses the items to be added from the form generated by the multi input and
 
1570 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1571 assembly items are renumbered and the sums recalculated via
 
1572 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1574 =item C<action_show_multi_items_dialog>
 
1576 =item C<action_multi_items_update_result>
 
1578 =item C<action_add_makemodel_row>
 
1580 Add a new makemodel row with the vendor that was selected via the vendor
 
1583 Checks the already existing makemodels and warns if a row with that vendor
 
1584 already exists. Currently it is possible to have duplicate vendor rows.
 
1586 =item C<action_reorder_items>
 
1588 Sorts the item table for assembly or assortment items.
 
1590 =item C<action_warehouse_changed>
 
1594 =head1 ACTIONS part picker
 
1598 =item C<action_ajax_autocomplete>
 
1600 =item C<action_test_page>
 
1602 =item C<action_part_picker_search>
 
1604 =item C<action_part_picker_result>
 
1606 =item C<action_show>
 
1616 Calls some simple checks that test the submitted $::form for obvious errors.
 
1617 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1619 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1620 some cases extra actions are taken, e.g. if the part description is missing the
 
1621 basic data tab is selected and the description input field is focussed.
 
1627 =item C<form_check_part_description_exists>
 
1629 =item C<form_check_assortment_items_exist>
 
1631 =item C<form_check_assortment_items_unique>
 
1633 =item C<form_check_assembly_items_exist>
 
1635 =item C<form_check_partnumber_is_unique>
 
1639 =head1 HELPER FUNCTIONS
 
1645 When submitting the form for saving, parses the transmitted form. Expects the
 
1649  $::form->{makemodels}
 
1650  $::form->{translations}
 
1652  $::form->{assemblies}
 
1653  $::form->{assortments}
 
1655 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1657 =item C<recalc_item_totals %params>
 
1659 Helper function for calculating the total lastcost and sellprice for assemblies
 
1660 or assortments according to their items, which are parsed from the current
 
1663 Is called whenever the qty of an item is changed or items are deleted.
 
1667 * part_type : 'assortment' or 'assembly' (mandatory)
 
1669 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1671 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1673 Doesn't work for recursive items.
 
1677 =head1 GET SET INITS
 
1679 There are get_set_inits for
 
1687 which parse $::form and automatically create an array of objects.
 
1689 These inits are used during saving and each time a new element is added.
 
1693 =item C<init_makemodels>
 
1695 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1696 $self->part->makemodels, ready to be saved.
 
1698 Used for saving parts and adding new makemodel rows.
 
1700 =item C<parse_add_items_to_objects PART_TYPE>
 
1702 Parses the resulting form from either the part-picker submit or the multi-item
 
1703 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1704 can be rendered via C<render_assortment_items_to_html> or
 
1705 C<render_assembly_items_to_html>.
 
1707 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1708 Optional param: position (used for numbering and listrow class)
 
1710 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1712 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1713 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1714 assortment items are added.
 
1716 =item C<parse_form_makemodels>
 
1718 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1719 remembers when the lastcost for that vendor changed the last time.
 
1721 So the original values are cloned and remembered, so we can compare if lastcost
 
1722 was changed in $::form, and keep or update lastupdate.
 
1724 lastcost isn't updated until the first time it was saved with a value, until
 
1727 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1728 makemodel entries exist or not.
 
1730 We still need init_makemodels for when we open the part for editing.
 
1740 It should be possible to jump to the edit page in a specific tab
 
1744 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1745 back to the order again afterwards.
 
1749 Support units when adding assembly items or assortment items. Currently the
 
1750 default unit of the item is always used.
 
1754 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1755 consists of other assemblies.
 
1761 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>