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);
 
 163 sub action_save_and_purchase_order {
 
 167   if (1 == scalar @{$self->part->makemodels}) {
 
 168     my $prepared_form           = Form->new('');
 
 169     $prepared_form->{vendor_id} = $self->part->makemodels->[0]->make;
 
 170     $session_value              = $::auth->save_form_in_session(form => $prepared_form);
 
 173   $::form->{callback} = $self->url_for(
 
 174     controller   => 'Order',
 
 175     action       => 'return_from_create_part',
 
 176     type         => 'purchase_order',
 
 177     previousform => $session_value,
 
 180   $self->_run_action('save');
 
 186   if ( $::form->{callback} ) {
 
 187     $self->redirect_to($::form->unescape($::form->{callback}));
 
 194   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 196   my $partnumber = $self->part->partnumber; # remember for history log
 
 201       # delete part, together with relationships that don't already
 
 202       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 203       $self->part->delete(cascade => 1);
 
 205       SL::DB::History->new(
 
 206         trans_id    => $self->part->id,
 
 207         snumbers    => 'partnumber_' . $partnumber,
 
 208         employee_id => SL::DB::Manager::Employee->current->id,
 
 210         addition    => 'DELETED',
 
 213   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 215   flash_later('info', t8('The item has been deleted.'));
 
 216   if ( $::form->{callback} ) {
 
 217     $self->redirect_to($::form->unescape($::form->{callback}));
 
 219     $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
 
 223 sub action_use_as_new {
 
 224   my ($self, %params) = @_;
 
 226   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 227   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 229   $self->part($oldpart->clone_and_reset_deep);
 
 231   $self->part->partnumber(undef);
 
 237   my ($self, %params) = @_;
 
 243   my ($self, %params) = @_;
 
 245   $self->_set_javascript;
 
 246   $self->_setup_form_action_bar;
 
 248   my (%assortment_vars, %assembly_vars);
 
 249   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 250   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 252   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 254   if (scalar @{ $params{CUSTOM_VARIABLES} }) {
 
 255     CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
 
 256     $params{CUSTOM_VARIABLES_FIRST_TAB}       = [];
 
 257     @{ $params{CUSTOM_VARIABLES_FIRST_TAB} }  = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
 
 260   my %title_hash = ( part       => t8('Edit Part'),
 
 261                      assembly   => t8('Edit Assembly'),
 
 262                      service    => t8('Edit Service'),
 
 263                      assortment => t8('Edit Assortment'),
 
 266   $self->part->prices([])       unless $self->part->prices;
 
 267   $self->part->translations([]) unless $self->part->translations;
 
 271     title             => $title_hash{$self->part->part_type},
 
 274     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 275     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 276     oldpartnumber     => $::form->{oldpartnumber},
 
 277     old_id            => $::form->{old_id},
 
 285   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 286   $_[0]->render('part/history', { layout => 0 },
 
 287                                   history_entries => $history_entries);
 
 290 sub action_inventory {
 
 293   $::auth->assert('warehouse_contents');
 
 295   $self->stock_amounts($self->part->get_simple_stock_sql);
 
 296   $self->journal($self->part->get_mini_journal);
 
 298   $_[0]->render('part/_inventory_data', { layout => 0 });
 
 301 sub action_update_item_totals {
 
 304   my $part_type = $::form->{part_type};
 
 305   die unless $part_type =~ /^(assortment|assembly)$/;
 
 307   my $sellprice_sum    = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 308   my $lastcost_sum     = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 309   my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
 
 311   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 314     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 315     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 316     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 317     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 318     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 319     ->html('#items_weight_sum_basic'   , $::form->format_amount(\%::myconfig, $items_weight_sum))
 
 320     ->no_flash_clear->render();
 
 323 sub action_add_multi_assortment_items {
 
 326   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 327   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 329   $self->js->run('kivi.Part.close_picker_dialogs')
 
 330            ->append('#assortment_rows', $html)
 
 331            ->run('kivi.Part.renumber_positions')
 
 332            ->run('kivi.Part.assortment_recalc')
 
 336 sub action_add_multi_assembly_items {
 
 339   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 341   foreach my $item (@{$item_objects}) {
 
 342     my $errstr = validate_assembly($item->part,$self->part);
 
 343     $self->js->flash('error',$errstr) if     $errstr;
 
 344     push (@checked_objects,$item)     unless $errstr;
 
 347   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 349   $self->js->run('kivi.Part.close_picker_dialogs')
 
 350            ->append('#assembly_rows', $html)
 
 351            ->run('kivi.Part.renumber_positions')
 
 352            ->run('kivi.Part.assembly_recalc')
 
 356 sub action_add_assortment_item {
 
 357   my ($self, %params) = @_;
 
 359   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 361   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 363   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 364   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 365     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 368   my $number_of_items = scalar @{$self->assortment_items};
 
 369   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 370   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 372   push(@{$self->assortment_items}, @{$item_objects});
 
 373   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 374   $part->assortment_items(@{$self->assortment_items});
 
 375   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 376   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 377   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 380     ->append('#assortment_rows'        , $html)  # append in tbody
 
 381     ->val('.add_assortment_item_input' , '')
 
 382     ->run('kivi.Part.focus_last_assortment_input')
 
 383     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 384     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 385     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 386     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 387     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 391 sub action_add_assembly_item {
 
 394   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 396   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 398   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 400   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 401   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 402     $duplicate_warning++;
 
 405   my $number_of_items = scalar @{$self->assembly_items};
 
 406   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 408     foreach my $item (@{$item_objects}) {
 
 409       my $errstr = validate_assembly($item->part,$self->part);
 
 410       return $self->js->flash('error',$errstr)->render if $errstr;
 
 415   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 417   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 419   push(@{$self->assembly_items}, @{$item_objects});
 
 420   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 421   $part->assemblies(@{$self->assembly_items});
 
 422   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 423   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 424   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 425   my $items_weight_sum    = $part->items_weight_sum;
 
 428     ->append('#assembly_rows', $html)  # append in tbody
 
 429     ->val('.add_assembly_item_input' , '')
 
 430     ->run('kivi.Part.focus_last_assembly_input')
 
 431     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 432     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 433     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 434     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 435     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 436     ->html('#items_weight_sum_basic'   , $::form->format_amount(\%::myconfig, $items_weight_sum))
 
 440 sub action_show_multi_items_dialog {
 
 443   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 444   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 445   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 447   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 448                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
 
 449                 search_term     => $search_term
 
 453 sub action_multi_items_update_result {
 
 454   my $max_count = $::form->{limit};
 
 456   my $count = $_[0]->multi_items_models->count;
 
 459     my $text = escape($::locale->text('No results.'));
 
 460     $_[0]->render($text, { layout => 0 });
 
 461   } elsif ($max_count && $count > $max_count) {
 
 462     my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 463     $_[0]->render($text, { layout => 0 });
 
 465     my $multi_items = $_[0]->multi_items_models->get;
 
 466     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 467                   multi_items => $multi_items);
 
 471 sub action_add_makemodel_row {
 
 474   my $vendor_id = $::form->{add_makemodel};
 
 476   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 477     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 479   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 480     $self->js->flash('info', t8("This vendor has already been added."));
 
 483   my $position = scalar @{$self->makemodels} + 1;
 
 485   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 489                                   sortorder    => $position,
 
 490                                  ) or die "Can't create MakeModel object";
 
 492   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 494                                      listrow   => $position % 2 ? 0 : 1,
 
 497   # after selection focus on the model field in the row that was just added
 
 499     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 500     ->val('.add_makemodel_input', '')
 
 501     ->run('kivi.Part.focus_last_makemodel_input')
 
 505 sub action_add_customerprice_row {
 
 508   my $customer_id = $::form->{add_customerprice};
 
 510   my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
 
 511     or return $self->js->error(t8("No customer selected or found!"))->render;
 
 513   if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
 
 514     $self->js->flash('info', t8("This customer has already been added."));
 
 517   my $position = scalar @{ $self->customerprices } + 1;
 
 519   my $cu = SL::DB::PartCustomerPrice->new(
 
 520                       customer_id         => $customer->id,
 
 521                       customer_partnumber => '',
 
 523                       sortorder           => $position,
 
 524   ) or die "Can't create Customerprice object";
 
 526   my $row_as_html = $self->p->render(
 
 527                                      'part/_customerprice_row',
 
 528                                       customerprice => $cu,
 
 529                                       listrow       => $position % 2 ? 0
 
 533   $self->js->append('#customerprice_rows', $row_as_html)    # append in tbody
 
 534            ->val('.add_customerprice_input', '')
 
 535            ->run('kivi.Part.focus_last_customerprice_input')->render;
 
 538 sub action_reorder_items {
 
 541   my $part_type = $::form->{part_type};
 
 544     partnumber  => sub { $_[0]->part->partnumber },
 
 545     description => sub { $_[0]->part->description },
 
 546     qty         => sub { $_[0]->qty },
 
 547     sellprice   => sub { $_[0]->part->sellprice },
 
 548     lastcost    => sub { $_[0]->part->lastcost },
 
 549     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 552   my $method = $sort_keys{$::form->{order_by}};
 
 555   if ($part_type eq 'assortment') {
 
 556     @items = @{ $self->assortment_items };
 
 558     @items = @{ $self->assembly_items };
 
 561   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 562   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 563     if ($::form->{sort_dir}) {
 
 564       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 566       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 569     if ($::form->{sort_dir}) {
 
 570       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 572       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 576   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 579 sub action_warehouse_changed {
 
 582   if ($::form->{warehouse_id} ) {
 
 583     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 584     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 586     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 587       $self->bin($self->warehouse->bins_sorted->[0]);
 
 589         ->html('#bin', $self->build_bin_select)
 
 590         ->focus('#part_bin_id');
 
 591       return $self->js->render;
 
 595   # no warehouse was selected, empty the bin field and reset the id
 
 597        ->val('#part_bin_id', undef)
 
 600   return $self->js->render;
 
 603 sub action_ajax_autocomplete {
 
 604   my ($self, %params) = @_;
 
 606   # if someone types something, and hits enter, assume he entered the full name.
 
 607   # if something matches, treat that as sole match
 
 608   # since we need a second get models instance with different filters for that,
 
 609   # we only modify the original filter temporarily in place
 
 610   if ($::form->{prefer_exact}) {
 
 611     local $::form->{filter}{'all::ilike'}                          = delete local $::form->{filter}{'all:substr:multi::ilike'};
 
 612     local $::form->{filter}{'all_with_makemodel::ilike'}           = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
 
 613     local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
 
 615     my $exact_models = SL::Controller::Helper::GetModels->new(
 
 618       paginated    => { per_page => 2 },
 
 619       with_objects => [ qw(unit_obj classification) ],
 
 622     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
 
 623       $self->parts($exact_matches);
 
 629      value       => $_->displayable_name,
 
 630      label       => $_->displayable_name,
 
 632      partnumber  => $_->partnumber,
 
 633      description => $_->description,
 
 635      part_type   => $_->part_type,
 
 637      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 639   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 641   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 644 sub action_test_page {
 
 645   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 648 sub action_part_picker_search {
 
 651   my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
 
 652   $search_term  ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
 
 653   $search_term  ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
 
 655   $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
 
 658 sub action_part_picker_result {
 
 659   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
 
 665   if ($::request->type eq 'json') {
 
 670       $part_hash          = $self->part->as_tree;
 
 671       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 674     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 679 sub validate_add_items {
 
 680   scalar @{$::form->{add_items}};
 
 683 sub prepare_assortment_render_vars {
 
 686   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 687                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 688                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 690   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 695 sub prepare_assembly_render_vars {
 
 698   croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
 
 700   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 701                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 702                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 704   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 712   check_has_valid_part_type($self->part->part_type);
 
 714   $self->_set_javascript;
 
 715   $self->_setup_form_action_bar;
 
 717   my %title_hash = ( part       => t8('Add Part'),
 
 718                      assembly   => t8('Add Assembly'),
 
 719                      service    => t8('Add Service'),
 
 720                      assortment => t8('Add Assortment'),
 
 725     title => $title_hash{$self->part->part_type},
 
 730 sub _set_javascript {
 
 732   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart kivi.Validator);
 
 733   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 736 sub recalc_item_totals {
 
 737   my ($self, %params) = @_;
 
 739   if ( $params{part_type} eq 'assortment' ) {
 
 740     return 0 unless scalar @{$self->assortment_items};
 
 741   } elsif ( $params{part_type} eq 'assembly' ) {
 
 742     return 0 unless scalar @{$self->assembly_items};
 
 744     carp "can only calculate sum for assortments and assemblies";
 
 747   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 748   if ( $part->is_assortment ) {
 
 749     $part->assortment_items( @{$self->assortment_items} );
 
 750     if ( $params{price_type} eq 'lastcost' ) {
 
 751       return $part->items_lastcost_sum;
 
 753       if ( $params{pricegroup_id} ) {
 
 754         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 756         return $part->items_sellprice_sum;
 
 759   } elsif ( $part->is_assembly ) {
 
 760     $part->assemblies( @{$self->assembly_items} );
 
 761     if ( $params{price_type} eq 'weight' ) {
 
 762       return $part->items_weight_sum;
 
 763     } elsif ( $params{price_type} eq 'lastcost' ) {
 
 764       return $part->items_lastcost_sum;
 
 766       return $part->items_sellprice_sum;
 
 771 sub check_part_not_modified {
 
 774   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 781   my $is_new = !$self->part->id;
 
 783   my $params = delete($::form->{part}) || { };
 
 785   delete $params->{id};
 
 786   $self->part->assign_attributes(%{ $params});
 
 787   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 789   $self->normalize_text_blocks;
 
 791   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 792   # will be the case for used assortments when saving, or when a used assortment
 
 794   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 795     $self->part->assortment_items([]);
 
 796     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 799   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 800     $self->part->assemblies([]); # completely rewrite assortments each time
 
 801     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 804   $self->part->translations([]);
 
 805   $self->parse_form_translations;
 
 807   $self->part->prices([]);
 
 808   $self->parse_form_prices;
 
 810   $self->parse_form_customerprices;
 
 811   $self->parse_form_makemodels;
 
 814 sub parse_form_prices {
 
 816   # only save prices > 0
 
 817   my $prices = delete($::form->{prices}) || [];
 
 818   foreach my $price ( @{$prices} ) {
 
 819     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 820     next unless $sellprice > 0; # skip negative prices as well
 
 821     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 822                                pricegroup_id => $price->{pricegroup_id},
 
 825     $self->part->add_prices($p);
 
 829 sub parse_form_translations {
 
 831   # don't add empty translations
 
 832   my $translations = delete($::form->{translations}) || [];
 
 833   foreach my $translation ( @{$translations} ) {
 
 834     next unless $translation->{translation};
 
 835     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 836     $self->part->add_translations( $translation );
 
 840 sub parse_form_makemodels {
 
 844   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 845     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 848   $self->part->makemodels([]);
 
 851   my $makemodels = delete($::form->{makemodels}) || [];
 
 852   foreach my $makemodel ( @{$makemodels} ) {
 
 853     next unless $makemodel->{make};
 
 855     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 857     my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
 
 858     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 860                                      make       => $makemodel->{make},
 
 861                                      model      => $makemodel->{model} || '',
 
 862                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 863                                      sortorder  => $position,
 
 865     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 866       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 867       # don't change lastupdate
 
 868     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 869       # new makemodel, no lastcost entered, leave lastupdate empty
 
 870     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 871       # lastcost hasn't changed, use original lastupdate
 
 872       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 874       $mm->lastupdate(DateTime->now);
 
 876     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 877     $self->part->add_makemodels($mm);
 
 881 sub parse_form_customerprices {
 
 884   my $customerprices_map;
 
 885   if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
 
 886     $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
 
 889   $self->part->customerprices([]);
 
 892   my $customerprices = delete($::form->{customerprices}) || [];
 
 893   foreach my $customerprice ( @{$customerprices} ) {
 
 894     next unless $customerprice->{customer_id};
 
 896     my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
 
 898     my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
 
 899     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
 901                                      customer_id          => $customerprice->{customer_id},
 
 902                                      customer_partnumber  => $customerprice->{customer_partnumber} || '',
 
 903                                      price                => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
 
 904                                      sortorder            => $position,
 
 906     if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
 
 907       # lastupdate isn't set, original price is 0 and new lastcost is 0
 
 908       # don't change lastupdate
 
 909     } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
 
 910       # new customerprice, no lastcost entered, leave lastupdate empty
 
 911     } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
 
 912       # price hasn't changed, use original lastupdate
 
 913       $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
 
 915       $cu->lastupdate(DateTime->now);
 
 917     $self->part->add_customerprices($cu);
 
 921 sub build_bin_select {
 
 922   select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
 
 923     title_key => 'description',
 
 924     default   => $_[0]->bin->id,
 
 929 # get_set_inits for partpicker
 
 932   if ($::form->{no_paginate}) {
 
 933     $_[0]->models->disable_plugin('paginated');
 
 939 # get_set_inits for part controller
 
 943   # used by edit, save, delete and add
 
 945   if ( $::form->{part}{id} ) {
 
 946     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
 
 947   } elsif ( $::form->{id} ) {
 
 948     return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
 
 950     die "part_type missing" unless $::form->{part}{part_type};
 
 951     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 957   return $self->part->orphaned;
 
 963   SL::Controller::Helper::GetModels->new(
 
 970       partnumber  => t8('Partnumber'),
 
 971       description  => t8('Description'),
 
 973     with_objects => [ qw(unit_obj classification) ],
 
 982 sub init_assortment_items {
 
 983   # this init is used while saving and whenever assortments change dynamically
 
 987   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 988   foreach my $assortment_item ( @{$assortment_items} ) {
 
 989     next unless $assortment_item->{parts_id};
 
 991     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 992     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 993                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 994                                           charge        => $assortment_item->{charge},
 
 995                                           unit          => $assortment_item->{unit} || $part->unit,
 
 996                                           position      => $position,
 
1004 sub init_makemodels {
 
1008   my @makemodel_array = ();
 
1009   my $makemodels = delete($::form->{makemodels}) || [];
 
1011   foreach my $makemodel ( @{$makemodels} ) {
 
1012     next unless $makemodel->{make};
 
1014     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
1015                                     id        => $makemodel->{id},
 
1016                                     make      => $makemodel->{make},
 
1017                                     model     => $makemodel->{model} || '',
 
1018                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
1019                                     sortorder => $position,
 
1020                                   ) or die "Can't create mm";
 
1021     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
1022     push(@makemodel_array, $mm);
 
1024   return \@makemodel_array;
 
1027 sub init_customerprices {
 
1031   my @customerprice_array = ();
 
1032   my $customerprices = delete($::form->{customerprices}) || [];
 
1034   foreach my $customerprice ( @{$customerprices} ) {
 
1035     next unless $customerprice->{customer_id};
 
1037     my $cu = SL::DB::PartCustomerPrice->new( # parts_id   => $self->part->id, # will be assigned by row add_customerprices
 
1038                                     id                  => $customerprice->{id},
 
1039                                     customer_partnumber => $customerprice->{customer_partnumber},
 
1040                                     customer_id         => $customerprice->{customer_id} || '',
 
1041                                     price               => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
 
1042                                     sortorder           => $position,
 
1043                                   ) or die "Can't create cu";
 
1044     # $cu->id($customerprice->{id}) if $customerprice->{id};
 
1045     push(@customerprice_array, $cu);
 
1047   return \@customerprice_array;
 
1050 sub init_assembly_items {
 
1054   my $assembly_items = delete($::form->{assembly_items}) || [];
 
1055   foreach my $assembly_item ( @{$assembly_items} ) {
 
1056     next unless $assembly_item->{parts_id};
 
1058     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
1059     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1060                                    bom         => $assembly_item->{bom},
 
1061                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
1062                                    position    => $position,
 
1069 sub init_all_warehouses {
 
1071   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
1074 sub init_all_languages {
 
1075   SL::DB::Manager::Language->get_all_sorted;
 
1078 sub init_all_partsgroups {
 
1080   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
1083 sub init_all_buchungsgruppen {
 
1085   if ( $self->part->orphaned ) {
 
1086     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
1088     return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
 
1092 sub init_shops_not_assigned {
 
1095   my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
 
1096   if ( @used_shop_ids ) {
 
1097     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
 
1100     return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
 
1104 sub init_all_units {
 
1106   if ( $self->part->orphaned ) {
 
1107     return SL::DB::Manager::Unit->get_all_sorted;
 
1109     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
1113 sub init_all_payment_terms {
 
1115   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
1118 sub init_all_price_factors {
 
1119   SL::DB::Manager::PriceFactor->get_all_sorted;
 
1122 sub init_all_pricegroups {
 
1123   SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]);
 
1126 # model used to filter/display the parts in the multi-items dialog
 
1127 sub init_multi_items_models {
 
1128   SL::Controller::Helper::GetModels->new(
 
1129     controller     => $_[0],
 
1131     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
1132     disable_plugin => 'paginated',
 
1133     source         => $::form->{multi_items},
 
1139       partnumber  => t8('Partnumber'),
 
1140       description => t8('Description')}
 
1144 sub init_parts_classification_filter {
 
1145   return [] unless $::form->{parts_classification_type};
 
1147   return [ used_for_sale     => 't' ] if $::form->{parts_classification_type} eq 'sales';
 
1148   return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
 
1150   die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
 
1153 # simple checks to run on $::form before saving
 
1155 sub form_check_part_description_exists {
 
1158   return 1 if $::form->{part}{description};
 
1160   $self->js->flash('error', t8('Part Description missing!'))
 
1161            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
1162            ->focus('#part_description');
 
1166 sub form_check_assortment_items_exist {
 
1169   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1170   # skip item check for existing assortments that have been used
 
1171   return 1 if ($self->part->id and !$self->part->orphaned);
 
1173   # new or orphaned parts must have items in $::form->{assortment_items}
 
1174   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
1175     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1176              ->focus('#add_assortment_item_name')
 
1177              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
1183 sub form_check_assortment_items_unique {
 
1186   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
1188   my %duplicate_elements;
 
1190   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
1191     $duplicate_elements{$_}++ if $count{$_}++;
 
1194   if ( keys %duplicate_elements ) {
 
1195     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
1196              ->flash('error', t8('There are duplicate assortment items'));
 
1202 sub form_check_assembly_items_exist {
 
1205   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1207   # skip item check for existing assembly that have been used
 
1208   return 1 if ($self->part->id and !$self->part->orphaned);
 
1210   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1211     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1212              ->focus('#add_assembly_item_name')
 
1213              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1219 sub form_check_partnumber_is_unique {
 
1222   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1223     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1225       $self->js->flash('error', t8('The partnumber already exists!'))
 
1226                ->focus('#part_description');
 
1233 # general checking functions
 
1236   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1242   $self->form_check_part_description_exists || return 0;
 
1243   $self->form_check_assortment_items_exist  || return 0;
 
1244   $self->form_check_assortment_items_unique || return 0;
 
1245   $self->form_check_assembly_items_exist    || return 0;
 
1246   $self->form_check_partnumber_is_unique    || return 0;
 
1251 sub check_has_valid_part_type {
 
1252   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1256 sub normalize_text_blocks {
 
1259   # check if feature is enabled (select normalize_part_descriptions from defaults)
 
1260   return unless ($::instance_conf->get_normalize_part_descriptions);
 
1263   foreach (qw(description)) {
 
1264     $self->part->{$_} =~ s/\s+$//s;
 
1265     $self->part->{$_} =~ s/^\s+//s;
 
1266     $self->part->{$_} =~ s/ {2,}/ /g;
 
1268   # html block (caveat: can be circumvented by using bold or italics)
 
1269   $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
 
1270   $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
 
1274 sub render_assortment_items_to_html {
 
1275   my ($self, $assortment_items, $number_of_items) = @_;
 
1277   my $position = $number_of_items + 1;
 
1279   foreach my $ai (@$assortment_items) {
 
1280     $html .= $self->p->render('part/_assortment_row',
 
1281                               PART     => $self->part,
 
1282                               orphaned => $self->orphaned,
 
1284                               listrow  => $position % 2 ? 1 : 0,
 
1285                               position => $position, # for legacy assemblies
 
1292 sub render_assembly_items_to_html {
 
1293   my ($self, $assembly_items, $number_of_items) = @_;
 
1295   my $position = $number_of_items + 1;
 
1297   foreach my $ai (@{$assembly_items}) {
 
1298     $html .= $self->p->render('part/_assembly_row',
 
1299                               PART     => $self->part,
 
1300                               orphaned => $self->orphaned,
 
1302                               listrow  => $position % 2 ? 1 : 0,
 
1303                               position => $position, # for legacy assemblies
 
1310 sub parse_add_items_to_objects {
 
1311   my ($self, %params) = @_;
 
1312   my $part_type = $params{part_type};
 
1313   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1314   my $position = $params{position} || 1;
 
1316   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1319   foreach my $item ( @add_items ) {
 
1320     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1322     if ( $part_type eq 'assortment' ) {
 
1323        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1324                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1325                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1326                                          position      => $position,
 
1327                                         ) or die "Can't create AssortmentItem from item";
 
1328     } elsif ( $part_type eq 'assembly' ) {
 
1329       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1330                                  # id          => $self->assembly->id, # will be set on save
 
1331                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1332                                  bom         => 0, # default when adding: no bom
 
1333                                  position    => $position,
 
1336       die "part_type must be assortment or assembly";
 
1338     push(@item_objects, $ai);
 
1342   return \@item_objects;
 
1345 sub _setup_form_action_bar {
 
1348   my $may_edit           = $::auth->assert('part_service_assembly_edit', 'may fail');
 
1349   my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
 
1351   for my $bar ($::request->layout->get('actionbar')) {
 
1356           call      => [ 'kivi.Part.save' ],
 
1357           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
 
1358           checks    => ['kivi.validate_form'],
 
1362           call     => [ 'kivi.Part.use_as_new' ],
 
1363           disabled => !$self->part->id ? t8('The object has not been saved yet.')
 
1364                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1367       ], # end of combobox "Save"
 
1370         action => [ t8('Workflow') ],
 
1372           t8('Save and Purchase Order'),
 
1373           submit   => [ '#ic', { action => "Part/save_and_purchase_order" } ],
 
1374           checks   => ['kivi.validate_form'],
 
1375           disabled => !$self->part->id                                    ? t8('The object has not been saved yet.')
 
1376                     : !$may_edit                                          ? t8('You do not have the permissions to access this function.')
 
1377                     : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.')
 
1379           only_if  => !$::form->{inline_create},
 
1385         submit   => [ '#ic', { action => "Part/abort" } ],
 
1386         only_if  => !!$::form->{inline_create},
 
1391         call     => [ 'kivi.Part.delete' ],
 
1392         confirm  => t8('Do you really want to delete this object?'),
 
1393         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
 
1394                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
 
1395                   : !$self->part->orphaned ? t8('This object has already been used.')
 
1396                   : $used_in_pricerules    ? t8('This object is used in price rules.')
 
1404         call     => [ 'kivi.Part.open_history_popup' ],
 
1405         disabled => !$self->part->id ? t8('This object has not been saved yet.')
 
1406                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
 
1421 SL::Controller::Part - Part CRUD controller
 
1425 Controller for adding/editing/saving/deleting parts.
 
1427 All the relations are loaded at once and saving the part, adding a history
 
1428 entry and saving CVars happens inside one transaction.  When saving the old
 
1429 relations are deleted and written as new to the database.
 
1431 Relations for parts:
 
1439 =item assembly items
 
1441 =item assortment items
 
1449 There are 4 different part types:
 
1455 The "default" part type.
 
1457 inventory_accno_id is set.
 
1461 Services can't be stocked.
 
1463 inventory_accno_id isn't set.
 
1467 Assemblies consist of other parts, services, assemblies or assortments. They
 
1468 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1469 have to make them, which reduces the stock by its respective components. Once
 
1470 an assembly item has been created there is currently no way to "disassemble" it
 
1471 again. An assembly item can appear several times in one assembly. An assmbly is
 
1472 sold as one item with a defined sellprice and lastcost. If the component prices
 
1473 change the assortment price remains the same. The assembly items may be printed
 
1474 in a record if the item's "bom" is set.
 
1478 Similar to assembly, but each assortment item may only appear once per
 
1479 assortment. When selling an assortment the assortment items are added to the
 
1480 record together with the assortment, which is added with sellprice 0.
 
1482 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1483 determined by the sum of the current assortment item prices when the assortment
 
1484 is added to a record. This also means that price rules and customer discounts
 
1485 will be applied to the assortment items.
 
1487 Once the assortment items have been added they may be modified or deleted, just
 
1488 as if they had been added manually, the individual assortment items aren't
 
1489 linked to the assortment or the other assortment items in any way.
 
1497 =item C<action_add_part>
 
1499 =item C<action_add_service>
 
1501 =item C<action_add_assembly>
 
1503 =item C<action_add_assortment>
 
1505 =item C<action_add PART_TYPE>
 
1507 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1508 parameter part_type as an action. Example:
 
1510   controller.pl?action=Part/add&part_type=service
 
1512 =item C<action_add_from_record>
 
1514 When adding new items to records they can be created on the fly if the entered
 
1515 partnumber or description doesn't exist yet. After being asked what part type
 
1516 the new item should have the user is redirected to the correct edit page.
 
1518 Depending on whether the item was added from a sales or a purchase record, only
 
1519 the relevant part classifications should be selectable for new item, so this
 
1520 parameter is passed on via a hidden parts_classification_type in the new_item
 
1523 =item C<action_save>
 
1525 Saves the current part and then reloads the edit page for the part.
 
1527 =item C<action_use_as_new>
 
1529 Takes the information from the current part, plus any modifications made on the
 
1530 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1531 set empty, so a new partnumber from the number range will be used if the user
 
1532 doesn't enter one manually.
 
1534 Unsaved changes to the original part aren't updated.
 
1536 The part type cannot be changed in this way.
 
1538 =item C<action_delete>
 
1540 Deletes the current part and then redirects to the main page, there is no
 
1543 The delete button only appears if the part is 'orphaned', according to
 
1544 SL::DB::Part orphaned.
 
1546 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1547 the inventory, or is part of an assembly or assortment.
 
1549 If the part is deleted its relations prices, makdemodel, assembly,
 
1550 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1552 Before this controller items that appeared in inventory didn't count as
 
1553 orphaned and could be deleted and the inventory entries were also deleted, this
 
1554 "feature" hasn't been implemented.
 
1556 =item C<action_edit part.id>
 
1558 Load and display a part for editing.
 
1560   controller.pl?action=Part/edit&part.id=12345
 
1562 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1566 =head1 BUTTON ACTIONS
 
1572 Opens a popup displaying all the history entries. Once a new history controller
 
1573 is written the button could link there instead, with the part already selected.
 
1581 =item C<action_update_item_totals>
 
1583 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1584 amount of an item changes. The sum of all sellprices and lastcosts is
 
1585 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1587 =item C<action_add_assortment_item>
 
1589 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1591 If the item already exists in the assortment the item isn't added and a Flash
 
1594 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1595 after adding each new item, add the new object to the item objects that were
 
1596 already parsed, calculate totals via a dummy part then update the row and the
 
1599 =item C<action_add_assembly_item>
 
1601 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1603 If the item already exists in the assembly a flash info is generated, but the
 
1606 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1607 after adding each new item, add the new object to the item objects that were
 
1608 already parsed, calculate totals via a dummy part then update the row and the
 
1611 =item C<action_add_multi_assortment_items>
 
1613 Parses the items to be added from the form generated by the multi input and
 
1614 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1615 assortment items are renumbered and the sums recalculated via
 
1616 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1618 =item C<action_add_multi_assembly_items>
 
1620 Parses the items to be added from the form generated by the multi input and
 
1621 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1622 assembly items are renumbered and the sums recalculated via
 
1623 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1625 =item C<action_show_multi_items_dialog>
 
1627 =item C<action_multi_items_update_result>
 
1629 =item C<action_add_makemodel_row>
 
1631 Add a new makemodel row with the vendor that was selected via the vendor
 
1634 Checks the already existing makemodels and warns if a row with that vendor
 
1635 already exists. Currently it is possible to have duplicate vendor rows.
 
1637 =item C<action_reorder_items>
 
1639 Sorts the item table for assembly or assortment items.
 
1641 =item C<action_warehouse_changed>
 
1645 =head1 ACTIONS part picker
 
1649 =item C<action_ajax_autocomplete>
 
1651 =item C<action_test_page>
 
1653 =item C<action_part_picker_search>
 
1655 =item C<action_part_picker_result>
 
1657 =item C<action_show>
 
1667 Calls some simple checks that test the submitted $::form for obvious errors.
 
1668 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1670 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1671 some cases extra actions are taken, e.g. if the part description is missing the
 
1672 basic data tab is selected and the description input field is focussed.
 
1678 =item C<form_check_part_description_exists>
 
1680 =item C<form_check_assortment_items_exist>
 
1682 =item C<form_check_assortment_items_unique>
 
1684 =item C<form_check_assembly_items_exist>
 
1686 =item C<form_check_partnumber_is_unique>
 
1690 =head1 HELPER FUNCTIONS
 
1696 When submitting the form for saving, parses the transmitted form. Expects the
 
1700  $::form->{makemodels}
 
1701  $::form->{translations}
 
1703  $::form->{assemblies}
 
1704  $::form->{assortments}
 
1706 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1708 =item C<recalc_item_totals %params>
 
1710 Helper function for calculating the total lastcost and sellprice for assemblies
 
1711 or assortments according to their items, which are parsed from the current
 
1714 Is called whenever the qty of an item is changed or items are deleted.
 
1718 * part_type : 'assortment' or 'assembly' (mandatory)
 
1720 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1722 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1724 Doesn't work for recursive items.
 
1728 =head1 GET SET INITS
 
1730 There are get_set_inits for
 
1738 which parse $::form and automatically create an array of objects.
 
1740 These inits are used during saving and each time a new element is added.
 
1744 =item C<init_makemodels>
 
1746 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1747 $self->part->makemodels, ready to be saved.
 
1749 Used for saving parts and adding new makemodel rows.
 
1751 =item C<parse_add_items_to_objects PART_TYPE>
 
1753 Parses the resulting form from either the part-picker submit or the multi-item
 
1754 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1755 can be rendered via C<render_assortment_items_to_html> or
 
1756 C<render_assembly_items_to_html>.
 
1758 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1759 Optional param: position (used for numbering and listrow class)
 
1761 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1763 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1764 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1765 assortment items are added.
 
1767 =item C<parse_form_makemodels>
 
1769 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1770 remembers when the lastcost for that vendor changed the last time.
 
1772 So the original values are cloned and remembered, so we can compare if lastcost
 
1773 was changed in $::form, and keep or update lastupdate.
 
1775 lastcost isn't updated until the first time it was saved with a value, until
 
1778 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1779 makemodel entries exist or not.
 
1781 We still need init_makemodels for when we open the part for editing.
 
1791 It should be possible to jump to the edit page in a specific tab
 
1795 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1796 back to the order again afterwards.
 
1800 Support units when adding assembly items or assortment items. Currently the
 
1801 default unit of the item is always used.
 
1805 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1806 consists of other assemblies.
 
1812 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>