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 List::UtilsBy qw(extract_by);
 
  16 use SL::Helper::Flash;
 
  20 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  22 use SL::MoreCommon qw(save_form);
 
  24 use SL::Presenter::EscapedText qw(escape is_escaped);
 
  25 use SL::Presenter::Tag qw(select_tag);
 
  27 use Rose::Object::MakeMethods::Generic (
 
  28   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  29                                   makemodels shops_not_assigned
 
  32                                   assortment assortment_items assembly assembly_items
 
  33                                   all_pricegroups all_translations all_partsgroups all_units
 
  34                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  35                                   parts_classification_filter
 
  36                                   all_languages all_units all_price_factors) ],
 
  37   'scalar'                => [ qw(warehouse bin stock_amounts journal) ],
 
  41 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  42                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  44 __PACKAGE__->run_before(sub { $::auth->assert('developer') },
 
  45                         only => [ qw(test_page) ]);
 
  47 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  49 # actions for editing parts
 
  52   my ($self, %params) = @_;
 
  54   $self->part( SL::DB::Part->new_part );
 
  58 sub action_add_service {
 
  59   my ($self, %params) = @_;
 
  61   $self->part( SL::DB::Part->new_service );
 
  65 sub action_add_assembly {
 
  66   my ($self, %params) = @_;
 
  68   $self->part( SL::DB::Part->new_assembly );
 
  72 sub action_add_assortment {
 
  73   my ($self, %params) = @_;
 
  75   $self->part( SL::DB::Part->new_assortment );
 
  79 sub action_add_from_record {
 
  82   check_has_valid_part_type($::form->{part}{part_type});
 
  84   die 'parts_classification_type must be "sales" or "purchases"'
 
  85     unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
 
  94   check_has_valid_part_type($::form->{part_type});
 
  96   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  97   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  98   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  99   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
 103   my ($self, %params) = @_;
 
 105   # checks that depend only on submitted $::form
 
 106   $self->check_form or return $self->js->render;
 
 108   my $is_new = !$self->part->id; # $ part gets loaded here
 
 110   # check that the part hasn't been modified
 
 112     $self->check_part_not_modified or
 
 113       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;
 
 117        && $::form->{part}{partnumber}
 
 118        && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
 
 120     return $self->js->error(t8('The partnumber is already being used'))->render;
 
 125   my @errors = $self->part->validate;
 
 126   return $self->js->error(@errors)->render if @errors;
 
 128   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 129   $self->part->db->with_transaction(sub {
 
 131     $self->part->save(cascade => 1);
 
 133     SL::DB::History->new(
 
 134       trans_id    => $self->part->id,
 
 135       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 136       employee_id => SL::DB::Manager::Employee->current->id,
 
 141     CVar->save_custom_variables(
 
 142       dbh           => $self->part->db->dbh,
 
 144       trans_id      => $self->part->id,
 
 145       variables     => $::form, # $::form->{cvar} would be nicer
 
 150   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 152   flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
 
 154   if ( $::form->{callback} ) {
 
 155     $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
 
 158     # default behaviour after save: reload item, this also resets last_modification!
 
 159     $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 166   if ( $::form->{callback} ) {
 
 167     $self->redirect_to($::form->unescape($::form->{callback}));
 
 174   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 176   my $partnumber = $self->part->partnumber; # remember for history log
 
 181       # delete part, together with relationships that don't already
 
 182       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 183       $self->part->delete(cascade => 1);
 
 185       SL::DB::History->new(
 
 186         trans_id    => $self->part->id,
 
 187         snumbers    => 'partnumber_' . $partnumber,
 
 188         employee_id => SL::DB::Manager::Employee->current->id,
 
 190         addition    => 'DELETED',
 
 193   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 195   flash_later('info', t8('The item has been deleted.'));
 
 196   if ( $::form->{callback} ) {
 
 197     $self->redirect_to($::form->unescape($::form->{callback}));
 
 199     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 203 sub action_use_as_new {
 
 204   my ($self, %params) = @_;
 
 206   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 207   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 209   $self->part($oldpart->clone_and_reset_deep);
 
 211   $self->part->partnumber(undef);
 
 217   my ($self, %params) = @_;
 
 223   my ($self, %params) = @_;
 
 225   $self->_set_javascript;
 
 226   $self->_setup_form_action_bar;
 
 228   my (%assortment_vars, %assembly_vars);
 
 229   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 230   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 232   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 234   if (scalar @{ $params{CUSTOM_VARIABLES} }) {
 
 235     CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
 
 236     $params{CUSTOM_VARIABLES_FIRST_TAB}       = [];
 
 237     @{ $params{CUSTOM_VARIABLES_FIRST_TAB} }  = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
 
 240   my %title_hash = ( part       => t8('Edit Part'),
 
 241                      assembly   => t8('Edit Assembly'),
 
 242                      service    => t8('Edit Service'),
 
 243                      assortment => t8('Edit Assortment'),
 
 246   $self->part->prices([])       unless $self->part->prices;
 
 247   $self->part->translations([]) unless $self->part->translations;
 
 251     title             => $title_hash{$self->part->part_type},
 
 254     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 255     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 256     oldpartnumber     => $::form->{oldpartnumber},
 
 257     old_id            => $::form->{old_id},
 
 265   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 266   $_[0]->render('part/history', { layout => 0 },
 
 267                                   history_entries => $history_entries);
 
 270 sub action_inventory {
 
 273   $::auth->assert('warehouse_contents');
 
 275   $self->stock_amounts($self->part->get_simple_stock_sql);
 
 276   $self->journal($self->part->get_mini_journal);
 
 278   $_[0]->render('part/_inventory_data', { layout => 0 });
 
 281 sub action_update_item_totals {
 
 284   my $part_type = $::form->{part_type};
 
 285   die unless $part_type =~ /^(assortment|assembly)$/;
 
 287   my $sellprice_sum    = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 288   my $lastcost_sum     = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 289   my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
 
 291   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 294     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 295     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 296     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 297     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 298     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 299     ->html('#items_weight_sum_basic'   , $::form->format_amount(\%::myconfig, $items_weight_sum))
 
 300     ->no_flash_clear->render();
 
 303 sub action_add_multi_assortment_items {
 
 306   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 307   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 309   $self->js->run('kivi.Part.close_picker_dialogs')
 
 310            ->append('#assortment_rows', $html)
 
 311            ->run('kivi.Part.renumber_positions')
 
 312            ->run('kivi.Part.assortment_recalc')
 
 316 sub action_add_multi_assembly_items {
 
 319   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 321   foreach my $item (@{$item_objects}) {
 
 322     my $errstr = validate_assembly($item->part,$self->part);
 
 323     $self->js->flash('error',$errstr) if     $errstr;
 
 324     push (@checked_objects,$item)     unless $errstr;
 
 327   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 329   $self->js->run('kivi.Part.close_picker_dialogs')
 
 330            ->append('#assembly_rows', $html)
 
 331            ->run('kivi.Part.renumber_positions')
 
 332            ->run('kivi.Part.assembly_recalc')
 
 336 sub action_add_assortment_item {
 
 337   my ($self, %params) = @_;
 
 339   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 341   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 343   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 344   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 345     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 348   my $number_of_items = scalar @{$self->assortment_items};
 
 349   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 350   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 352   push(@{$self->assortment_items}, @{$item_objects});
 
 353   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 354   $part->assortment_items(@{$self->assortment_items});
 
 355   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 356   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 357   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 360     ->append('#assortment_rows'        , $html)  # append in tbody
 
 361     ->val('.add_assortment_item_input' , '')
 
 362     ->run('kivi.Part.focus_last_assortment_input')
 
 363     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 364     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 365     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 366     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 367     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 371 sub action_add_assembly_item {
 
 374   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 376   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 378   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 380   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 381   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 382     $duplicate_warning++;
 
 385   my $number_of_items = scalar @{$self->assembly_items};
 
 386   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 388     foreach my $item (@{$item_objects}) {
 
 389       my $errstr = validate_assembly($item->part,$self->part);
 
 390       return $self->js->flash('error',$errstr)->render if $errstr;
 
 395   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 397   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 399   push(@{$self->assembly_items}, @{$item_objects});
 
 400   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 401   $part->assemblies(@{$self->assembly_items});
 
 402   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 403   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 404   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 405   my $items_weight_sum    = $part->items_weight_sum;
 
 408     ->append('#assembly_rows', $html)  # append in tbody
 
 409     ->val('.add_assembly_item_input' , '')
 
 410     ->run('kivi.Part.focus_last_assembly_input')
 
 411     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 412     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 413     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 414     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 415     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 416     ->html('#items_weight_sum_basic'   , $::form->format_amount(\%::myconfig, $items_weight_sum))
 
 420 sub action_show_multi_items_dialog {
 
 423   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 424   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 425   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 427   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 428                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
 
 429                 search_term     => $search_term
 
 433 sub action_multi_items_update_result {
 
 434   my $max_count = $::form->{limit};
 
 436   my $count = $_[0]->multi_items_models->count;
 
 439     my $text = escape($::locale->text('No results.'));
 
 440     $_[0]->render($text, { layout => 0 });
 
 441   } elsif ($max_count && $count > $max_count) {
 
 442     my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 443     $_[0]->render($text, { layout => 0 });
 
 445     my $multi_items = $_[0]->multi_items_models->get;
 
 446     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 447                   multi_items => $multi_items);
 
 451 sub action_add_makemodel_row {
 
 454   my $vendor_id = $::form->{add_makemodel};
 
 456   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 457     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 459   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 460     $self->js->flash('info', t8("This vendor has already been added."));
 
 463   my $position = scalar @{$self->makemodels} + 1;
 
 465   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 469                                   sortorder    => $position,
 
 470                                  ) or die "Can't create MakeModel object";
 
 472   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 474                                      listrow   => $position % 2 ? 0 : 1,
 
 477   # after selection focus on the model field in the row that was just added
 
 479     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 480     ->val('.add_makemodel_input', '')
 
 481     ->run('kivi.Part.focus_last_makemodel_input')
 
 485 sub action_add_customerprice_row {
 
 488   my $customer_id = $::form->{add_customerprice};
 
 490   my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
 
 491     or return $self->js->error(t8("No customer selected or found!"))->render;
 
 493   if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
 
 494     $self->js->flash('info', t8("This customer has already been added."));
 
 497   my $position = scalar @{ $self->customerprices } + 1;
 
 499   my $cu = SL::DB::PartCustomerPrice->new(
 
 500                       customer_id         => $customer->id,
 
 501                       customer_partnumber => '',
 
 503                       sortorder           => $position,
 
 504   ) or die "Can't create Customerprice object";
 
 506   my $row_as_html = $self->p->render(
 
 507                                      'part/_customerprice_row',
 
 508                                       customerprice => $cu,
 
 509                                       listrow       => $position % 2 ? 0
 
 513   $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
 
 514            ->val('.add_customerprice_input', '')
 
 515            ->run('kivi.Part.focus_last_customerprice_input')->render;
 
 518 sub action_reorder_items {
 
 521   my $part_type = $::form->{part_type};
 
 524     partnumber  => sub { $_[0]->part->partnumber },
 
 525     description => sub { $_[0]->part->description },
 
 526     qty         => sub { $_[0]->qty },
 
 527     sellprice   => sub { $_[0]->part->sellprice },
 
 528     lastcost    => sub { $_[0]->part->lastcost },
 
 529     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 532   my $method = $sort_keys{$::form->{order_by}};
 
 535   if ($part_type eq 'assortment') {
 
 536     @items = @{ $self->assortment_items };
 
 538     @items = @{ $self->assembly_items };
 
 541   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 542   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 543     if ($::form->{sort_dir}) {
 
 544       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 546       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 549     if ($::form->{sort_dir}) {
 
 550       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 552       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 556   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 559 sub action_warehouse_changed {
 
 562   if ($::form->{warehouse_id} ) {
 
 563     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 564     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 566     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 567       $self->bin($self->warehouse->bins_sorted->[0]);
 
 569         ->html('#bin', $self->build_bin_select)
 
 570         ->focus('#part_bin_id');
 
 571       return $self->js->render;
 
 575   # no warehouse was selected, empty the bin field and reset the id
 
 577        ->val('#part_bin_id', undef)
 
 580   return $self->js->render;
 
 583 sub action_ajax_autocomplete {
 
 584   my ($self, %params) = @_;
 
 586   # if someone types something, and hits enter, assume he entered the full name.
 
 587   # if something matches, treat that as sole match
 
 588   # since we need a second get models instance with different filters for that,
 
 589   # we only modify the original filter temporarily in place
 
 590   if ($::form->{prefer_exact}) {
 
 591     local $::form->{filter}{'all::ilike'}                          = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 592     local $::form->{filter}{'all_with_makemodel::ilike'}           = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
 
 593     local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
 
 595     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 598       paginated    => { per_page => 2 },
 
 599       with_objects => [ qw(unit_obj classification) ],
 
 602     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 603       $self->parts($exact_matches);
 
 609      value       => $_->displayable_name,
 
 610      label       => $_->displayable_name,
 
 612      partnumber  => $_->partnumber,
 
 613      description => $_->description,
 
 615      part_type   => $_->part_type,
 
 617      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 619   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 621   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 624 sub action_test_page {
 
 625   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 628 sub action_part_picker_search {
 
 631   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 632   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 633   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 635   $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
 
 638 sub action_part_picker_result {
 
 639   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 645   if ($::request->type eq 'json') {
 
 650       $part_hash          = $self->part->as_tree;
 
 651       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 654     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 659 sub validate_add_items {
 
 660   scalar @{$::form->{add_items}};
 
 663 sub prepare_assortment_render_vars {
 
 666   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 667                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 668                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 670   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 675 sub prepare_assembly_render_vars {
 
 678   croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
 
 680   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 681                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 682                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 684   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 692   check_has_valid_part_type($self->part->part_type);
 
 694   $self->_set_javascript;
 
 695   $self->_setup_form_action_bar;
 
 697   my %title_hash = ( part       => t8('Add Part'),
 
 698                      assembly   => t8('Add Assembly'),
 
 699                      service    => t8('Add Service'),
 
 700                      assortment => t8('Add Assortment'),
 
 705     title => $title_hash{$self->part->part_type},
 
 710 sub _set_javascript {
 
 712   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
 
 713   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 716 sub recalc_item_totals {
 
 717   my ($self, %params) = @_;
 
 719   if ( $params{part_type} eq 'assortment' ) {
 
 720     return 0 unless scalar @{$self->assortment_items};
 
 721   } elsif ( $params{part_type} eq 'assembly' ) {
 
 722     return 0 unless scalar @{$self->assembly_items};
 
 724     carp "can only calculate sum for assortments and assemblies";
 
 727   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 728   if ( $part->is_assortment ) {
 
 729     $part->assortment_items( @{$self->assortment_items} );
 
 730     if ( $params{price_type} eq 'lastcost' ) {
 
 731       return $part->items_lastcost_sum;
 
 733       if ( $params{pricegroup_id} ) {
 
 734         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 736         return $part->items_sellprice_sum;
 
 739   } elsif ( $part->is_assembly ) {
 
 740     $part->assemblies( @{$self->assembly_items} );
 
 741     if ( $params{price_type} eq 'weight' ) {
 
 742       return $part->items_weight_sum;
 
 743     } elsif ( $params{price_type} eq 'lastcost' ) {
 
 744       return $part->items_lastcost_sum;
 
 746       return $part->items_sellprice_sum;
 
 751 sub check_part_not_modified {
 
 754   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 761   my $is_new = !$self->part->id;
 
 763   my $params = delete($::form->{part}) || { };
 
 765   delete $params->{id};
 
 766   $self->part->assign_attributes(%{ $params});
 
 767   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 769   $self->normalize_text_blocks;
 
 771   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 772   # will be the case for used assortments when saving, or when a used assortment
 
 774   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 775     $self->part->assortment_items([]);
 
 776     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 779   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 780     $self->part->assemblies([]); # completely rewrite assortments each time
 
 781     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 784   $self->part->translations([]);
 
 785   $self->parse_form_translations;
 
 787   $self->part->prices([]);
 
 788   $self->parse_form_prices;
 
 790   $self->parse_form_customerprices;
 
 791   $self->parse_form_makemodels;
 
 794 sub parse_form_prices {
 
 796   # only save prices > 0
 
 797   my $prices = delete($::form->{prices}) || [];
 
 798   foreach my $price ( @{$prices} ) {
 
 799     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 800     next unless $sellprice > 0; # skip negative prices as well
 
 801     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 802                                pricegroup_id => $price->{pricegroup_id},
 
 805     $self->part->add_prices($p);
 
 809 sub parse_form_translations {
 
 811   # don't add empty translations
 
 812   my $translations = delete($::form->{translations}) || [];
 
 813   foreach my $translation ( @{$translations} ) {
 
 814     next unless $translation->{translation};
 
 815     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 816     $self->part->add_translations( $translation );
 
 820 sub parse_form_makemodels {
 
 824   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 825     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 828   $self->part->makemodels([]);
 
 831   my $makemodels = delete($::form->{makemodels}) || [];
 
 832   foreach my $makemodel ( @{$makemodels} ) {
 
 833     next unless $makemodel->{make};
 
 835     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 837     my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
 
 838     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 840                                      make       => $makemodel->{make},
 
 841                                      model      => $makemodel->{model} || '',
 
 842                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 843                                      sortorder  => $position,
 
 845     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 846       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 847       # don't change lastupdate
 
 848     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 849       # new makemodel, no lastcost entered, leave lastupdate empty
 
 850     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 851       # lastcost hasn't changed, use original lastupdate
 
 852       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 854       $mm->lastupdate(DateTime->now);
 
 856     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 857     $self->part->add_makemodels($mm);
 
 861 sub parse_form_customerprices {
 
 864   my $customerprices_map;
 
 865   if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
 
 866     $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
 
 869   $self->part->customerprices([]);
 
 872   my $customerprices = delete($::form->{customerprices}) || [];
 
 873   foreach my $customerprice ( @{$customerprices} ) {
 
 874     next unless $customerprice->{customer_id};
 
 876     my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
 
 878     my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
 
 879     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 881                                      customer_id          => $customerprice->{customer_id},
 
 882                                      customer_partnumber  => $customerprice->{customer_partnumber} || '',
 
 883                                      price                => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
 
 884                                      sortorder            => $position,
 
 886     if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
 
 887       # lastupdate isn't set, original price is 0 and new lastcost is 0
 
 888       # don't change lastupdate
 
 889     } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
 
 890       # new customerprice, no lastcost entered, leave lastupdate empty
 
 891     } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
 
 892       # price hasn't changed, use original lastupdate
 
 893       $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
 
 895       $cu->lastupdate(DateTime->now);
 
 897     $self->part->add_customerprices($cu);
 
 901 sub build_bin_select {
 
 902   select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
 
 903     title_key => 'description',
 
 904     default   => $_[0]->bin->id,
 
 909 # get_set_inits for partpicker
 
 912   if ($::form->{no_paginate}) {
 
 913     $_[0]->models->disable_plugin('paginated');
 
 919 # get_set_inits for part controller
 
 923   # used by edit, save, delete and add
 
 925   if ( $::form->{part}{id} ) {
 
 926     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 927   } elsif ( $::form->{id} ) {
 
 928     return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
 
 930     die "part_type missing" unless $::form->{part}{part_type};
 
 931     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 937   return $self->part->orphaned;
 
 943   SL::Controller::Helper::GetModels->new(
 
 950       partnumber  => t8('Partnumber'),
 
 951       description  => t8('Description'),
 
 953     with_objects => [ qw(unit_obj classification) ],
 
 962 sub init_assortment_items {
 
 963   # this init is used while saving and whenever assortments change dynamically
 
 967   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 968   foreach my $assortment_item ( @{$assortment_items} ) {
 
 969     next unless $assortment_item->{parts_id};
 
 971     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 972     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 973                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 974                                           charge        => $assortment_item->{charge},
 
 975                                           unit          => $assortment_item->{unit} || $part->unit,
 
 976                                           position      => $position,
 
 984 sub init_makemodels {
 
 988   my @makemodel_array = ();
 
 989   my $makemodels = delete($::form->{makemodels}) || [];
 
 991   foreach my $makemodel ( @{$makemodels} ) {
 
 992     next unless $makemodel->{make};
 
 994     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 995                                     id        => $makemodel->{id},
 
 996                                     make      => $makemodel->{make},
 
 997                                     model     => $makemodel->{model} || '',
 
 998                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 999                                     sortorder => $position,
 
1000                                   ) or die "Can't create mm";
 
1001     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
1002     push(@makemodel_array, $mm);
 
1004   return \@makemodel_array;
 
1007 sub init_customerprices {
 
1011   my @customerprice_array = ();
 
1012   my $customerprices = delete($::form->{customerprices}) || [];
 
1014   foreach my $customerprice ( @{$customerprices} ) {
 
1015     next unless $customerprice->{customer_id};
 
1017     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
1018                                     id                  => $customerprice->{id},
 
1019                                     customer_partnumber => $customerprice->{customer_partnumber},
 
1020                                     customer_id         => $customerprice->{customer_id} || '',
 
1021                                     price               => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
 
1022                                     sortorder           => $position,
 
1023                                   ) or die "Can't create cu";
 
1024     # $cu->id($customerprice->{id}) if $customerprice->{id};
 
1025     push(@customerprice_array, $cu);
 
1027   return \@customerprice_array;
 
1030 sub init_assembly_items {
 
1034   my $assembly_items = delete($::form->{assembly_items}) || [];
 
1035   foreach my $assembly_item ( @{$assembly_items} ) {
 
1036     next unless $assembly_item->{parts_id};
 
1038     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
1039     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1040                                    bom         => $assembly_item->{bom},
 
1041                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
1042                                    position    => $position,
 
1049 sub init_all_warehouses {
 
1051   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
1054 sub init_all_languages {
 
1055   SL::DB::Manager::Language->get_all_sorted;
 
1058 sub init_all_partsgroups {
 
1060   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
1063 sub init_all_buchungsgruppen {
 
1065   if ( $self->part->orphaned ) {
 
1066     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
1068     return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
 
1072 sub init_shops_not_assigned {
 
1075   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
1076   if ( @used_shop_ids ) {
 
1077     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
1080     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
1084 sub init_all_units {
 
1086   if ( $self->part->orphaned ) {
 
1087     return SL::DB::Manager::Unit->get_all_sorted;
 
1089     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
1093 sub init_all_payment_terms {
 
1095   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
1098 sub init_all_price_factors {
 
1099   SL::DB::Manager::PriceFactor->get_all_sorted;
 
1102 sub init_all_pricegroups {
 
1103   SL::DB::Manager::Pricegroup->get_all_sorted;
 
1106 # model used to filter/display the parts in the multi-items dialog
 
1107 sub init_multi_items_models {
 
1108   SL::Controller::Helper::GetModels->new(
 
1109     controller     => $_[0],
 
1111     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
1112     disable_plugin => 'paginated',
 
1113     source         => $::form->{multi_items},
 
1119       partnumber  => t8('Partnumber'),
 
1120       description => t8('Description')}
 
1124 sub init_parts_classification_filter {
 
1125   return [] unless $::form->{parts_classification_type};
 
1127   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
1128   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
1130   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
1133 # simple checks to run on $::form before saving
 
1135 sub form_check_part_description_exists {
 
1138   return 1 if $::form->{part}{description};
 
1140   $self->js->flash('error', t8('Part Description missing!'))
 
1141            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
1142            ->focus('#part_description');
 
1146 sub form_check_assortment_items_exist {
 
1149   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1150   # skip item check for existing assortments that have been used
 
1151   return 1 if ($self->part->id and !$self->part->orphaned);
 
1153   # new or orphaned parts must have items in $::form->{assortment_items}
 
1154   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1155     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1156              ->focus('#add_assortment_item_name')
 
1157              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1163 sub form_check_assortment_items_unique {
 
1166   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1168   my %duplicate_elements;
 
1170   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1171     $duplicate_elements{$_}++ if $count{$_}++;
 
1174   if ( keys %duplicate_elements ) {
 
1175     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1176              ->flash('error', t8('There are duplicate assortment items'));
 
1182 sub form_check_assembly_items_exist {
 
1185   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1187   # skip item check for existing assembly that have been used
 
1188   return 1 if ($self->part->id and !$self->part->orphaned);
 
1190   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1191     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1192              ->focus('#add_assembly_item_name')
 
1193              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1199 sub form_check_partnumber_is_unique {
 
1202   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1203     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1205       $self->js->flash('error', t8('The partnumber already exists!'))
 
1206                ->focus('#part_description');
 
1213 # general checking functions
 
1216   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1222   $self->form_check_part_description_exists || return 0;
 
1223   $self->form_check_assortment_items_exist  || return 0;
 
1224   $self->form_check_assortment_items_unique || return 0;
 
1225   $self->form_check_assembly_items_exist    || return 0;
 
1226   $self->form_check_partnumber_is_unique    || return 0;
 
1231 sub check_has_valid_part_type {
 
1232   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1236 sub normalize_text_blocks {
 
1239   # check if feature is enabled (select normalize_part_descriptions from defaults)
 
1240   return unless ($::instance_conf->get_normalize_part_descriptions);
 
1243   foreach (qw(description)) {
 
1244     $self->part->{$_} =~ s/\s+$//s;
 
1245     $self->part->{$_} =~ s/^\s+//s;
 
1246     $self->part->{$_} =~ s/ {2,}/ /g;
 
1248   # html block (caveat: can be circumvented by using bold or italics)
 
1249   $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
 
1250   $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
 
1254 sub render_assortment_items_to_html {
 
1255   my ($self, $assortment_items, $number_of_items) = @_;
 
1257   my $position = $number_of_items + 1;
 
1259   foreach my $ai (@$assortment_items) {
 
1260     $html .= $self->p->render('part/_assortment_row',
 
1261                               PART     => $self->part,
 
1262                               orphaned => $self->orphaned,
 
1264                               listrow  => $position % 2 ? 1 : 0,
 
1265                               position => $position, # for legacy assemblies
 
1272 sub render_assembly_items_to_html {
 
1273   my ($self, $assembly_items, $number_of_items) = @_;
 
1275   my $position = $number_of_items + 1;
 
1277   foreach my $ai (@{$assembly_items}) {
 
1278     $html .= $self->p->render('part/_assembly_row',
 
1279                               PART     => $self->part,
 
1280                               orphaned => $self->orphaned,
 
1282                               listrow  => $position % 2 ? 1 : 0,
 
1283                               position => $position, # for legacy assemblies
 
1290 sub parse_add_items_to_objects {
 
1291   my ($self, %params) = @_;
 
1292   my $part_type = $params{part_type};
 
1293   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1294   my $position = $params{position} || 1;
 
1296   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1299   foreach my $item ( @add_items ) {
 
1300     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1302     if ( $part_type eq 'assortment' ) {
 
1303        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1304                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1305                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1306                                          position      => $position,
 
1307                                         ) or die "Can't create AssortmentItem from item";
 
1308     } elsif ( $part_type eq 'assembly' ) {
 
1309       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1310                                  # id          => $self->assembly->id, # will be set on save
 
1311                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1312                                  bom         => 0, # default when adding: no bom
 
1313                                  position    => $position,
 
1316       die "part_type must be assortment or assembly";
 
1318     push(@item_objects, $ai);
 
1322   return \@item_objects;
 
1325 sub _setup_form_action_bar {
 
1328   my $may_edit           = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1329   my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
 
1331   for my $bar ($::request->layout->get('actionbar')) {
 
1336           call      => [ 'kivi.Part.save' ],
 
1337           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1341           call     => [ 'kivi.Part.use_as_new' ],
 
1342           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1343                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1346       ], # end of combobox "Save"
 
1350         submit   => [ '#ic', { action => "Part/abort" } ],
 
1351         only_if  => !!$::form->{show_abort},
 
1356         call     => [ 'kivi.Part.delete' ],
 
1357         confirm  => t8('Do you really want to delete this object?'),
 
1358         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1359                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1360                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1361                   : $used_in_pricerules    ? t8('This object is used in price rules.')
 
1369         call     => [ 'kivi.Part.open_history_popup' ],
 
1370         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1371                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1386 SL::Controller::Part - Part CRUD controller
 
1390 Controller for adding/editing/saving/deleting parts.
 
1392 All the relations are loaded at once and saving the part, adding a history
 
1393 entry and saving CVars happens inside one transaction.  When saving the old
 
1394 relations are deleted and written as new to the database.
 
1396 Relations for parts:
 
1404 =item assembly items
 
1406 =item assortment items
 
1414 There are 4 different part types:
 
1420 The "default" part type.
 
1422 inventory_accno_id is set.
 
1426 Services can't be stocked.
 
1428 inventory_accno_id isn't set.
 
1432 Assemblies consist of other parts, services, assemblies or assortments. They
 
1433 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1434 have to make them, which reduces the stock by its respective components. Once
 
1435 an assembly item has been created there is currently no way to "disassemble" it
 
1436 again. An assembly item can appear several times in one assembly. An assmbly is
 
1437 sold as one item with a defined sellprice and lastcost. If the component prices
 
1438 change the assortment price remains the same. The assembly items may be printed
 
1439 in a record if the item's "bom" is set.
 
1443 Similar to assembly, but each assortment item may only appear once per
 
1444 assortment. When selling an assortment the assortment items are added to the
 
1445 record together with the assortment, which is added with sellprice 0.
 
1447 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1448 determined by the sum of the current assortment item prices when the assortment
 
1449 is added to a record. This also means that price rules and customer discounts
 
1450 will be applied to the assortment items.
 
1452 Once the assortment items have been added they may be modified or deleted, just
 
1453 as if they had been added manually, the individual assortment items aren't
 
1454 linked to the assortment or the other assortment items in any way.
 
1462 =item C<action_add_part>
 
1464 =item C<action_add_service>
 
1466 =item C<action_add_assembly>
 
1468 =item C<action_add_assortment>
 
1470 =item C<action_add PART_TYPE>
 
1472 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1473 parameter part_type as an action. Example:
 
1475   controller.pl?action=Part/add&part_type=service
 
1477 =item C<action_add_from_record>
 
1479 When adding new items to records they can be created on the fly if the entered
 
1480 partnumber or description doesn't exist yet. After being asked what part type
 
1481 the new item should have the user is redirected to the correct edit page.
 
1483 Depending on whether the item was added from a sales or a purchase record, only
 
1484 the relevant part classifications should be selectable for new item, so this
 
1485 parameter is passed on via a hidden parts_classification_type in the new_item
 
1488 =item C<action_save>
 
1490 Saves the current part and then reloads the edit page for the part.
 
1492 =item C<action_use_as_new>
 
1494 Takes the information from the current part, plus any modifications made on the
 
1495 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1496 set empty, so a new partnumber from the number range will be used if the user
 
1497 doesn't enter one manually.
 
1499 Unsaved changes to the original part aren't updated.
 
1501 The part type cannot be changed in this way.
 
1503 =item C<action_delete>
 
1505 Deletes the current part and then redirects to the main page, there is no
 
1508 The delete button only appears if the part is 'orphaned', according to
 
1509 SL::DB::Part orphaned.
 
1511 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1512 the inventory, or is part of an assembly or assortment.
 
1514 If the part is deleted its relations prices, makdemodel, assembly,
 
1515 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1517 Before this controller items that appeared in inventory didn't count as
 
1518 orphaned and could be deleted and the inventory entries were also deleted, this
 
1519 "feature" hasn't been implemented.
 
1521 =item C<action_edit part.id>
 
1523 Load and display a part for editing.
 
1525   controller.pl?action=Part/edit&part.id=12345
 
1527 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1531 =head1 BUTTON ACTIONS
 
1537 Opens a popup displaying all the history entries. Once a new history controller
 
1538 is written the button could link there instead, with the part already selected.
 
1546 =item C<action_update_item_totals>
 
1548 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1549 amount of an item changes. The sum of all sellprices and lastcosts is
 
1550 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1552 =item C<action_add_assortment_item>
 
1554 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1556 If the item already exists in the assortment the item isn't added and a Flash
 
1559 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1560 after adding each new item, add the new object to the item objects that were
 
1561 already parsed, calculate totals via a dummy part then update the row and the
 
1564 =item C<action_add_assembly_item>
 
1566 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1568 If the item already exists in the assembly a flash info is generated, but the
 
1571 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1572 after adding each new item, add the new object to the item objects that were
 
1573 already parsed, calculate totals via a dummy part then update the row and the
 
1576 =item C<action_add_multi_assortment_items>
 
1578 Parses the items to be added from the form generated by the multi input and
 
1579 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1580 assortment items are renumbered and the sums recalculated via
 
1581 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1583 =item C<action_add_multi_assembly_items>
 
1585 Parses the items to be added from the form generated by the multi input and
 
1586 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1587 assembly items are renumbered and the sums recalculated via
 
1588 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1590 =item C<action_show_multi_items_dialog>
 
1592 =item C<action_multi_items_update_result>
 
1594 =item C<action_add_makemodel_row>
 
1596 Add a new makemodel row with the vendor that was selected via the vendor
 
1599 Checks the already existing makemodels and warns if a row with that vendor
 
1600 already exists. Currently it is possible to have duplicate vendor rows.
 
1602 =item C<action_reorder_items>
 
1604 Sorts the item table for assembly or assortment items.
 
1606 =item C<action_warehouse_changed>
 
1610 =head1 ACTIONS part picker
 
1614 =item C<action_ajax_autocomplete>
 
1616 =item C<action_test_page>
 
1618 =item C<action_part_picker_search>
 
1620 =item C<action_part_picker_result>
 
1622 =item C<action_show>
 
1632 Calls some simple checks that test the submitted $::form for obvious errors.
 
1633 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1635 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1636 some cases extra actions are taken, e.g. if the part description is missing the
 
1637 basic data tab is selected and the description input field is focussed.
 
1643 =item C<form_check_part_description_exists>
 
1645 =item C<form_check_assortment_items_exist>
 
1647 =item C<form_check_assortment_items_unique>
 
1649 =item C<form_check_assembly_items_exist>
 
1651 =item C<form_check_partnumber_is_unique>
 
1655 =head1 HELPER FUNCTIONS
 
1661 When submitting the form for saving, parses the transmitted form. Expects the
 
1665  $::form->{makemodels}
 
1666  $::form->{translations}
 
1668  $::form->{assemblies}
 
1669  $::form->{assortments}
 
1671 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1673 =item C<recalc_item_totals %params>
 
1675 Helper function for calculating the total lastcost and sellprice for assemblies
 
1676 or assortments according to their items, which are parsed from the current
 
1679 Is called whenever the qty of an item is changed or items are deleted.
 
1683 * part_type : 'assortment' or 'assembly' (mandatory)
 
1685 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1687 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1689 Doesn't work for recursive items.
 
1693 =head1 GET SET INITS
 
1695 There are get_set_inits for
 
1703 which parse $::form and automatically create an array of objects.
 
1705 These inits are used during saving and each time a new element is added.
 
1709 =item C<init_makemodels>
 
1711 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1712 $self->part->makemodels, ready to be saved.
 
1714 Used for saving parts and adding new makemodel rows.
 
1716 =item C<parse_add_items_to_objects PART_TYPE>
 
1718 Parses the resulting form from either the part-picker submit or the multi-item
 
1719 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1720 can be rendered via C<render_assortment_items_to_html> or
 
1721 C<render_assembly_items_to_html>.
 
1723 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1724 Optional param: position (used for numbering and listrow class)
 
1726 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1728 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1729 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1730 assortment items are added.
 
1732 =item C<parse_form_makemodels>
 
1734 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1735 remembers when the lastcost for that vendor changed the last time.
 
1737 So the original values are cloned and remembered, so we can compare if lastcost
 
1738 was changed in $::form, and keep or update lastupdate.
 
1740 lastcost isn't updated until the first time it was saved with a value, until
 
1743 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1744 makemodel entries exist or not.
 
1746 We still need init_makemodels for when we open the part for editing.
 
1756 It should be possible to jump to the edit page in a specific tab
 
1760 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1761 back to the order again afterwards.
 
1765 Support units when adding assembly items or assortment items. Currently the
 
1766 default unit of the item is always used.
 
1770 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1771 consists of other assemblies.
 
1777 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>