1 package SL::Controller::Part;
 
   4 use parent qw(SL::Controller::Base);
 
   8 use SL::Controller::Helper::GetModels;
 
   9 use SL::Locale::String qw(t8);
 
  11 use List::Util qw(sum);
 
  12 use SL::Helper::Flash;
 
  16 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
 
  20 use Rose::Object::MakeMethods::Generic (
 
  21   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  24                                   assortment assortment_items assembly assembly_items
 
  25                                   all_pricegroups all_translations all_partsgroups all_units
 
  26                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  27                                   all_languages all_units all_price_factors) ],
 
  28   'scalar'                => [ qw(warehouse bin) ],
 
  32 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  33                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  35 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  37 # actions for editing parts
 
  40   my ($self, %params) = @_;
 
  42   $self->part( SL::DB::Part->new_part );
 
  46 sub action_add_service {
 
  47   my ($self, %params) = @_;
 
  49   $self->part( SL::DB::Part->new_service );
 
  53 sub action_add_assembly {
 
  54   my ($self, %params) = @_;
 
  56   $self->part( SL::DB::Part->new_assembly );
 
  60 sub action_add_assortment {
 
  61   my ($self, %params) = @_;
 
  63   $self->part( SL::DB::Part->new_assortment );
 
  70   check_has_valid_part_type($::form->{part_type});
 
  72   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  73   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  74   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  75   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  79   my ($self, %params) = @_;
 
  81   # checks that depend only on submitted $::form
 
  82   $self->check_form or return $self->js->render;
 
  84   my $is_new = !$self->part->id; # $ part gets loaded here
 
  86   # check that the part hasn't been modified
 
  88     $self->check_part_not_modified or
 
  89       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;
 
  92   if ( $is_new and !$::form->{part}{partnumber} ) {
 
  93     $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
 
  98   my @errors = $self->part->validate;
 
  99   return $self->js->error(@errors)->render if @errors;
 
 101   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 102   $self->part->db->with_transaction(sub {
 
 104     if ( $params{save_as_new} ) {
 
 105       $self->part( $self->part->clone_and_reset_deep );
 
 106       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 109     $self->part->save(cascade => 1);
 
 111     SL::DB::History->new(
 
 112       trans_id    => $self->part->id,
 
 113       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 114       employee_id => SL::DB::Manager::Employee->current->id,
 
 119     CVar->save_custom_variables(
 
 120         dbh          => $self->part->db->dbh,
 
 122         trans_id     => $self->part->id,
 
 123         variables    => $::form, # $::form->{cvar} would be nicer
 
 128   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 130   flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
 
 132   # reload item, this also resets last_modification!
 
 133   $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 136 sub action_save_as_new {
 
 138   $self->action_save(save_as_new=>1);
 
 144   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 146   my $partnumber = $self->part->partnumber; # remember for history log
 
 151       # delete part, together with relationships that don't already
 
 152       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 153       $self->part->delete(cascade => 1);
 
 155       SL::DB::History->new(
 
 156         trans_id    => $self->part->id,
 
 157         snumbers    => 'partnumber_' . $partnumber,
 
 158         employee_id => SL::DB::Manager::Employee->current->id,
 
 160         addition    => 'DELETED',
 
 163   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 165   flash_later('info', t8('The item has been deleted.'));
 
 166   my @redirect_params = (
 
 167     controller => 'controller.pl',
 
 168     action => 'LoginScreen/user_login'
 
 170   $self->redirect_to(@redirect_params);
 
 173 sub action_use_as_new {
 
 174   my ($self, %params) = @_;
 
 176   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 177   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 179   $self->part($oldpart->clone_and_reset_deep);
 
 181   $self->part->partnumber(undef);
 
 187   my ($self, %params) = @_;
 
 193   my ($self, %params) = @_;
 
 195   $self->_set_javascript;
 
 197   my (%assortment_vars, %assembly_vars);
 
 198   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 199   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 201   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 203   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 204     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 206   my %title_hash = ( part       => t8('Edit Part'),
 
 207                      assembly   => t8('Edit Assembly'),
 
 208                      service    => t8('Edit Service'),
 
 209                      assortment => t8('Edit Assortment'),
 
 212   $self->part->prices([])       unless $self->part->prices;
 
 213   $self->part->translations([]) unless $self->part->translations;
 
 217     title             => $title_hash{$self->part->part_type},
 
 218     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
 
 221     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 222     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 223     oldpartnumber     => $::form->{oldpartnumber},
 
 224     old_id            => $::form->{old_id},
 
 232   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 233   $_[0]->render('part/history', { layout => 0 },
 
 234                                   history_entries => $history_entries);
 
 237 sub action_update_item_totals {
 
 240   my $part_type = $::form->{part_type};
 
 241   die unless $part_type =~ /^(assortment|assembly)$/;
 
 243   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 244   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 246   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 249     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 250     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 251     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 252     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 253     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 254     ->no_flash_clear->render();
 
 257 sub action_add_multi_assortment_items {
 
 260   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 261   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 263   $self->js->run('kivi.Part.close_multi_items_dialog')
 
 264            ->append('#assortment_rows', $html)
 
 265            ->run('kivi.Part.renumber_positions')
 
 266            ->run('kivi.Part.assortment_recalc')
 
 270 sub action_add_multi_assembly_items {
 
 273   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 275   foreach my $item (@{$item_objects}) {
 
 276     my $errstr = validate_assembly($item->part,$self->part);
 
 277     $self->js->flash('error',$errstr) if     $errstr;
 
 278     push (@checked_objects,$item)     unless $errstr;
 
 281   my $html = $self->render_assembly_items_to_html(\@checked_objects);
 
 283   $self->js->run('kivi.Part.close_multi_items_dialog')
 
 284            ->append('#assembly_rows', $html)
 
 285            ->run('kivi.Part.renumber_positions')
 
 286            ->run('kivi.Part.assembly_recalc')
 
 290 sub action_add_assortment_item {
 
 291   my ($self, %params) = @_;
 
 293   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 295   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 297   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 298   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 299     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 302   my $number_of_items = scalar @{$self->assortment_items};
 
 303   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 304   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 306   push(@{$self->assortment_items}, @{$item_objects});
 
 307   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 308   $part->assortment_items(@{$self->assortment_items});
 
 309   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 310   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 311   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 314     ->append('#assortment_rows'        , $html)  # append in tbody
 
 315     ->val('.add_assortment_item_input' , '')
 
 316     ->run('kivi.Part.focus_last_assortment_input')
 
 317     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 318     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 319     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 320     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 321     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 325 sub action_add_assembly_item {
 
 328   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 330   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 332   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 334   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 335   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 336     $duplicate_warning++;
 
 339   my $number_of_items = scalar @{$self->assembly_items};
 
 340   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 342     foreach my $item (@{$item_objects}) {
 
 343       my $errstr = validate_assembly($item->part,$self->part);
 
 344       return $self->js->flash('error',$errstr)->render if $errstr;
 
 349   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 351   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 353   push(@{$self->assembly_items}, @{$item_objects});
 
 354   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 355   $part->assemblies(@{$self->assembly_items});
 
 356   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 357   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 358   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 361     ->append('#assembly_rows', $html)  # append in tbody
 
 362     ->val('.add_assembly_item_input' , '')
 
 363     ->run('kivi.Part.focus_last_assembly_input')
 
 364     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 365     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 366     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 367     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 368     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 372 sub action_show_multi_items_dialog {
 
 373   require SL::DB::PartsGroup;
 
 374   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 375                 part_type => 'assortment',
 
 376                 partfilter => '', # can I get at the current input of the partpicker here?
 
 377                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
 
 380 sub action_multi_items_update_result {
 
 383   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 385   my $count = $_[0]->multi_items_models->count;
 
 388     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 389     $_[0]->render($text, { layout => 0 });
 
 390   } elsif ($count > $max_count) {
 
 391     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 392     $_[0]->render($text, { layout => 0 });
 
 394     my $multi_items = $_[0]->multi_items_models->get;
 
 395     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 396                   multi_items => $multi_items);
 
 400 sub action_add_makemodel_row {
 
 403   my $vendor_id = $::form->{add_makemodel};
 
 405   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 406     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 408   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 409     $self->js->flash('info', t8("This vendor has already been added."));
 
 412   my $position = scalar @{$self->makemodels} + 1;
 
 414   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 418                                   sortorder    => $position,
 
 419                                  ) or die "Can't create MakeModel object";
 
 421   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 423                                      listrow   => $position % 2 ? 0 : 1,
 
 426   # after selection focus on the model field in the row that was just added
 
 428     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 429     ->val('.add_makemodel_input', '')
 
 430     ->run('kivi.Part.focus_last_makemodel_input')
 
 434 sub action_reorder_items {
 
 437   my $part_type = $::form->{part_type};
 
 440     partnumber  => sub { $_[0]->part->partnumber },
 
 441     description => sub { $_[0]->part->description },
 
 442     qty         => sub { $_[0]->qty },
 
 443     sellprice   => sub { $_[0]->part->sellprice },
 
 444     lastcost    => sub { $_[0]->part->lastcost },
 
 445     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 448   my $method = $sort_keys{$::form->{order_by}};
 
 451   if ($part_type eq 'assortment') {
 
 452     @items = @{ $self->assortment_items };
 
 454     @items = @{ $self->assembly_items };
 
 457   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 458   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 459     if ($::form->{sort_dir}) {
 
 460       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 462       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 465     if ($::form->{sort_dir}) {
 
 466       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 468       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 472   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 475 sub action_warehouse_changed {
 
 478   if ($::form->{warehouse_id} ) {
 
 479     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 480     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 482     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 483       $self->bin($self->warehouse->bins->[0]);
 
 485         ->html('#bin', $self->build_bin_select)
 
 486         ->focus('#part_bin_id');
 
 487       return $self->js->render;
 
 491   # no warehouse was selected, empty the bin field and reset the id
 
 493        ->val('#part_bin_id', undef)
 
 496   return $self->js->render;
 
 499 sub action_ajax_autocomplete {
 
 500   my ($self, %params) = @_;
 
 502   # if someone types something, and hits enter, assume he entered the full name.
 
 503   # if something matches, treat that as sole match
 
 504   # unfortunately get_models can't do more than one per package atm, so we d it
 
 505   # the oldfashioned way.
 
 506   if ($::form->{prefer_exact}) {
 
 508     if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
 
 511         SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
 
 512         SL::DB::Manager::PartClassification->classification_filter($::form->{filter}{classification_id}),
 
 514           description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
 
 515           partnumber  => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
 
 520       $self->parts($exact_matches);
 
 526      value       => $_->displayable_name,
 
 527      label       => $_->displayable_name,
 
 529      partnumber  => $_->partnumber,
 
 530      description => $_->description,
 
 531      part_type   => $_->part_type,
 
 533      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 535   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 537   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 540 sub action_test_page {
 
 541   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 544 sub action_part_picker_search {
 
 545   $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
 
 548 sub action_part_picker_result {
 
 549   $_[0]->render('part/_part_picker_result', { layout => 0 });
 
 555   if ($::request->type eq 'json') {
 
 560       $part_hash          = $self->part->as_tree;
 
 561       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 564     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 569 sub validate_add_items {
 
 570   scalar @{$::form->{add_items}};
 
 573 sub prepare_assortment_render_vars {
 
 576   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 577                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 578                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 580   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 585 sub prepare_assembly_render_vars {
 
 588   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 589                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 590                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 592   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 600   check_has_valid_part_type($self->part->part_type);
 
 602   $self->_set_javascript;
 
 604   my %title_hash = ( part       => t8('Add Part'),
 
 605                      assembly   => t8('Add Assembly'),
 
 606                      service    => t8('Add Service'),
 
 607                      assortment => t8('Add Assortment'),
 
 612     title             => $title_hash{$self->part->part_type},
 
 613     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
 
 618 sub _set_javascript {
 
 620   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
 
 621   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 624 sub recalc_item_totals {
 
 625   my ($self, %params) = @_;
 
 627   if ( $params{part_type} eq 'assortment' ) {
 
 628     return 0 unless scalar @{$self->assortment_items};
 
 629   } elsif ( $params{part_type} eq 'assembly' ) {
 
 630     return 0 unless scalar @{$self->assembly_items};
 
 632     carp "can only calculate sum for assortments and assemblies";
 
 635   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 636   if ( $part->is_assortment ) {
 
 637     $part->assortment_items( @{$self->assortment_items} );
 
 638     if ( $params{price_type} eq 'lastcost' ) {
 
 639       return $part->items_lastcost_sum;
 
 641       if ( $params{pricegroup_id} ) {
 
 642         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 644         return $part->items_sellprice_sum;
 
 647   } elsif ( $part->is_assembly ) {
 
 648     $part->assemblies( @{$self->assembly_items} );
 
 649     if ( $params{price_type} eq 'lastcost' ) {
 
 650       return $part->items_lastcost_sum;
 
 652       return $part->items_sellprice_sum;
 
 657 sub check_part_not_modified {
 
 660   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 667   my $is_new = !$self->part->id;
 
 669   my $params = delete($::form->{part}) || { };
 
 671   delete $params->{id};
 
 672   # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
 
 673   delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
 
 674   $self->part->assign_attributes(%{ $params});
 
 675   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 677   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 678   # will be the case for used assortments when saving, or when a used assortment
 
 680   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 681     $self->part->assortment_items([]);
 
 682     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 685   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 686     $self->part->assemblies([]); # completely rewrite assortments each time
 
 687     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 690   $self->part->translations([]);
 
 691   $self->parse_form_translations;
 
 693   $self->part->prices([]);
 
 694   $self->parse_form_prices;
 
 696   $self->parse_form_makemodels;
 
 699 sub parse_form_prices {
 
 701   # only save prices > 0
 
 702   my $prices = delete($::form->{prices}) || [];
 
 703   foreach my $price ( @{$prices} ) {
 
 704     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 705     next unless $sellprice > 0; # skip negative prices as well
 
 706     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 707                                pricegroup_id => $price->{pricegroup_id},
 
 710     $self->part->add_prices($p);
 
 714 sub parse_form_translations {
 
 716   # don't add empty translations
 
 717   my $translations = delete($::form->{translations}) || [];
 
 718   foreach my $translation ( @{$translations} ) {
 
 719     next unless $translation->{translation};
 
 720     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 721     $self->part->add_translations( $translation );
 
 725 sub parse_form_makemodels {
 
 729   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 730     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 733   $self->part->makemodels([]);
 
 736   my $makemodels = delete($::form->{makemodels}) || [];
 
 737   foreach my $makemodel ( @{$makemodels} ) {
 
 738     next unless $makemodel->{make};
 
 740     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 742     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 743                                      id         => $makemodel->{id},
 
 744                                      make       => $makemodel->{make},
 
 745                                      model      => $makemodel->{model} || '',
 
 746                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 747                                      sortorder  => $position,
 
 749     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 750       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 751       # don't change lastupdate
 
 752     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 753       # new makemodel, no lastcost entered, leave lastupdate empty
 
 754     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 755       # lastcost hasn't changed, use original lastupdate
 
 756       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 758       $mm->lastupdate(DateTime->now);
 
 760     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 761     $self->part->add_makemodels($mm);
 
 765 sub build_bin_select {
 
 766   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 767     title_key => 'description',
 
 768     default   => $_[0]->bin->id,
 
 772 # get_set_inits for partpicker
 
 775   if ($::form->{no_paginate}) {
 
 776     $_[0]->models->disable_plugin('paginated');
 
 782 # get_set_inits for part controller
 
 786   # used by edit, save, delete and add
 
 788   if ( $::form->{part}{id} ) {
 
 789     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
 
 791     die "part_type missing" unless $::form->{part}{part_type};
 
 792     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 798   return $self->part->orphaned;
 
 804   SL::Controller::Helper::GetModels->new(
 
 811       partnumber  => t8('Partnumber'),
 
 812       description  => t8('Description'),
 
 814     with_objects => [ qw(unit_obj classification) ],
 
 823 sub init_assortment_items {
 
 824   # this init is used while saving and whenever assortments change dynamically
 
 828   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 829   foreach my $assortment_item ( @{$assortment_items} ) {
 
 830     next unless $assortment_item->{parts_id};
 
 832     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 833     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 834                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 835                                           charge        => $assortment_item->{charge},
 
 836                                           unit          => $assortment_item->{unit} || $part->unit,
 
 837                                           position      => $position,
 
 845 sub init_makemodels {
 
 849   my @makemodel_array = ();
 
 850   my $makemodels = delete($::form->{makemodels}) || [];
 
 852   foreach my $makemodel ( @{$makemodels} ) {
 
 853     next unless $makemodel->{make};
 
 855     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 856                                     id        => $makemodel->{id},
 
 857                                     make      => $makemodel->{make},
 
 858                                     model     => $makemodel->{model} || '',
 
 859                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 860                                     sortorder => $position,
 
 861                                   ) or die "Can't create mm";
 
 862     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 863     push(@makemodel_array, $mm);
 
 865   return \@makemodel_array;
 
 868 sub init_assembly_items {
 
 872   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 873   foreach my $assembly_item ( @{$assembly_items} ) {
 
 874     next unless $assembly_item->{parts_id};
 
 876     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 877     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 878                                    bom         => $assembly_item->{bom},
 
 879                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 880                                    position    => $position,
 
 887 sub init_all_warehouses {
 
 889   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 892 sub init_all_languages {
 
 893   SL::DB::Manager::Language->get_all_sorted;
 
 896 sub init_all_partsgroups {
 
 898   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 901 sub init_all_buchungsgruppen {
 
 903   if ( $self->part->orphaned ) {
 
 904     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 906     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 912   if ( $self->part->orphaned ) {
 
 913     return SL::DB::Manager::Unit->get_all_sorted;
 
 915     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 919 sub init_all_payment_terms {
 
 921   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
 
 924 sub init_all_price_factors {
 
 925   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 928 sub init_all_pricegroups {
 
 929   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 932 # model used to filter/display the parts in the multi-items dialog
 
 933 sub init_multi_items_models {
 
 934   SL::Controller::Helper::GetModels->new(
 
 937     with_objects   => [ qw(unit_obj partsgroup classification) ],
 
 938     disable_plugin => 'paginated',
 
 939     source         => $::form->{multi_items},
 
 945       partnumber  => t8('Partnumber'),
 
 946       description => t8('Description')}
 
 950 # simple checks to run on $::form before saving
 
 952 sub form_check_part_description_exists {
 
 955   return 1 if $::form->{part}{description};
 
 957   $self->js->flash('error', t8('Part Description missing!'))
 
 958            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 959            ->focus('#part_description');
 
 963 sub form_check_assortment_items_exist {
 
 966   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 967   # skip check for existing parts that have been used
 
 968   return 1 if ($self->part->id and !$self->part->orphaned);
 
 970   # new or orphaned parts must have items in $::form->{assortment_items}
 
 971   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
 972     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 973              ->focus('#add_assortment_item_name')
 
 974              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
 980 sub form_check_assortment_items_unique {
 
 983   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 985   my %duplicate_elements;
 
 987   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
 988     $duplicate_elements{$_}++ if $count{$_}++;
 
 991   if ( keys %duplicate_elements ) {
 
 992     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 993              ->flash('error', t8('There are duplicate assortment items'));
 
 999 sub form_check_assembly_items_exist {
 
1002   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
1004   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
1005     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
1006              ->focus('#add_assembly_item_name')
 
1007              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
1013 sub form_check_partnumber_is_unique {
 
1016   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
1017     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
1019       $self->js->flash('error', t8('The partnumber already exists!'))
 
1020                ->focus('#part_description');
 
1027 # general checking functions
 
1028 sub check_next_transnumber_is_free {
 
1031   my ($next_transnumber, $count);
 
1032   $self->part->db->with_transaction(sub {
 
1033     $next_transnumber = $self->part->get_next_trans_number;
 
1034     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
 
1037   $count ? return 0 : return 1;
 
1041   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1047   $self->form_check_part_description_exists || return 0;
 
1048   $self->form_check_assortment_items_exist  || return 0;
 
1049   $self->form_check_assortment_items_unique || return 0;
 
1050   $self->form_check_assembly_items_exist    || return 0;
 
1051   $self->form_check_partnumber_is_unique    || return 0;
 
1056 sub check_has_valid_part_type {
 
1057   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1060 sub render_assortment_items_to_html {
 
1061   my ($self, $assortment_items, $number_of_items) = @_;
 
1063   my $position = $number_of_items + 1;
 
1065   foreach my $ai (@$assortment_items) {
 
1066     $html .= $self->p->render('part/_assortment_row',
 
1067                               PART     => $self->part,
 
1068                               orphaned => $self->orphaned,
 
1070                               listrow  => $position % 2 ? 1 : 0,
 
1071                               position => $position, # for legacy assemblies
 
1078 sub render_assembly_items_to_html {
 
1079   my ($self, $assembly_items, $number_of_items) = @_;
 
1081   my $position = $number_of_items + 1;
 
1083   foreach my $ai (@{$assembly_items}) {
 
1084     $html .= $self->p->render('part/_assembly_row',
 
1085                               PART     => $self->part,
 
1086                               orphaned => $self->orphaned,
 
1088                               listrow  => $position % 2 ? 1 : 0,
 
1089                               position => $position, # for legacy assemblies
 
1096 sub parse_add_items_to_objects {
 
1097   my ($self, %params) = @_;
 
1098   my $part_type = $params{part_type};
 
1099   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1100   my $position = $params{position} || 1;
 
1102   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1105   foreach my $item ( @add_items ) {
 
1106     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1108     if ( $part_type eq 'assortment' ) {
 
1109        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1110                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1111                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1112                                          position      => $position,
 
1113                                         ) or die "Can't create AssortmentItem from item";
 
1114     } elsif ( $part_type eq 'assembly' ) {
 
1115       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1116                                  # id          => $self->assembly->id, # will be set on save
 
1117                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1118                                  bom         => 0, # default when adding: no bom
 
1119                                  position    => $position,
 
1122       die "part_type must be assortment or assembly";
 
1124     push(@item_objects, $ai);
 
1128   return \@item_objects;
 
1139 SL::Controller::Part - Part CRUD controller
 
1143 Controller for adding/editing/saving/deleting parts.
 
1145 All the relations are loaded at once and saving the part, adding a history
 
1146 entry and saving CVars happens inside one transaction.  When saving the old
 
1147 relations are deleted and written as new to the database.
 
1149 Relations for parts:
 
1157 =item assembly items
 
1159 =item assortment items
 
1167 There are 4 different part types:
 
1173 The "default" part type.
 
1175 inventory_accno_id is set.
 
1179 Services can't be stocked.
 
1181 inventory_accno_id isn't set.
 
1185 Assemblies consist of other parts, services, assemblies or assortments. They
 
1186 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1187 have to make them, which reduces the stock by its respective components. Once
 
1188 an assembly item has been created there is currently no way to "disassemble" it
 
1189 again. An assembly item can appear several times in one assembly. An assmbly is
 
1190 sold as one item with a defined sellprice and lastcost. If the component prices
 
1191 change the assortment price remains the same. The assembly items may be printed
 
1192 in a record if the item's "bom" is set.
 
1196 Similar to assembly, but each assortment item may only appear once per
 
1197 assortment. When selling an assortment the assortment items are added to the
 
1198 record together with the assortment, which is added with sellprice 0.
 
1200 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1201 determined by the sum of the current assortment item prices when the assortment
 
1202 is added to a record. This also means that price rules and customer discounts
 
1203 will be applied to the assortment items.
 
1205 Once the assortment items have been added they may be modified or deleted, just
 
1206 as if they had been added manually, the individual assortment items aren't
 
1207 linked to the assortment or the other assortment items in any way.
 
1215 =item C<action_add_part>
 
1217 =item C<action_add_service>
 
1219 =item C<action_add_assembly>
 
1221 =item C<action_add_assortment>
 
1223 =item C<action_add PART_TYPE>
 
1225 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1226 parameter part_type as an action. Example:
 
1228   controller.pl?action=Part/add&part_type=service
 
1230 =item C<action_save>
 
1232 Saves the current part and then reloads the edit page for the part.
 
1234 =item C<action_use_as_new>
 
1236 Takes the information from the current part, plus any modifications made on the
 
1237 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1238 set empty, so a new partnumber from the number range will be used if the user
 
1239 doesn't enter one manually.
 
1241 Unsaved changes to the original part aren't updated.
 
1243 The part type cannot be changed in this way.
 
1245 =item C<action_delete>
 
1247 Deletes the current part and then redirects to the main page, there is no
 
1250 The delete button only appears if the part is 'orphaned', according to
 
1251 SL::DB::Part orphaned.
 
1253 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1254 the inventory, or is part of an assembly or assortment.
 
1256 If the part is deleted its relations prices, makdemodel, assembly,
 
1257 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1259 Before this controller items that appeared in inventory didn't count as
 
1260 orphaned and could be deleted and the inventory entries were also deleted, this
 
1261 "feature" hasn't been implemented.
 
1263 =item C<action_edit part.id>
 
1265 Load and display a part for editing.
 
1267   controller.pl?action=Part/edit&part.id=12345
 
1269 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1273 =head1 BUTTON ACTIONS
 
1279 Opens a popup displaying all the history entries. Once a new history controller
 
1280 is written the button could link there instead, with the part already selected.
 
1288 =item C<action_update_item_totals>
 
1290 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1291 amount of an item changes. The sum of all sellprices and lastcosts is
 
1292 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1294 =item C<action_add_assortment_item>
 
1296 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1298 If the item already exists in the assortment the item isn't added and a Flash
 
1301 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1302 after adding each new item, add the new object to the item objects that were
 
1303 already parsed, calculate totals via a dummy part then update the row and the
 
1306 =item C<action_add_assembly_item>
 
1308 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1310 If the item already exists in the assembly a flash info is generated, but the
 
1313 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1314 after adding each new item, add the new object to the item objects that were
 
1315 already parsed, calculate totals via a dummy part then update the row and the
 
1318 =item C<action_add_multi_assortment_items>
 
1320 Parses the items to be added from the form generated by the multi input and
 
1321 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1322 assortment items are renumbered and the sums recalculated via
 
1323 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1325 =item C<action_add_multi_assembly_items>
 
1327 Parses the items to be added from the form generated by the multi input and
 
1328 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1329 assembly items are renumbered and the sums recalculated via
 
1330 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1332 =item C<action_show_multi_items_dialog>
 
1334 =item C<action_multi_items_update_result>
 
1336 =item C<action_add_makemodel_row>
 
1338 Add a new makemodel row with the vendor that was selected via the vendor
 
1341 Checks the already existing makemodels and warns if a row with that vendor
 
1342 already exists. Currently it is possible to have duplicate vendor rows.
 
1344 =item C<action_reorder_items>
 
1346 Sorts the item table for assembly or assortment items.
 
1348 =item C<action_warehouse_changed>
 
1352 =head1 ACTIONS part picker
 
1356 =item C<action_ajax_autocomplete>
 
1358 =item C<action_test_page>
 
1360 =item C<action_part_picker_search>
 
1362 =item C<action_part_picker_result>
 
1364 =item C<action_show>
 
1374 Calls some simple checks that test the submitted $::form for obvious errors.
 
1375 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1377 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1378 some cases extra actions are taken, e.g. if the part description is missing the
 
1379 basic data tab is selected and the description input field is focussed.
 
1385 =item C<form_check_part_description_exists>
 
1387 =item C<form_check_assortment_items_exist>
 
1389 =item C<form_check_assortment_items_unique>
 
1391 =item C<form_check_assembly_items_exist>
 
1393 =item C<form_check_partnumber_is_unique>
 
1397 =head1 HELPER FUNCTIONS
 
1403 When submitting the form for saving, parses the transmitted form. Expects the
 
1407  $::form->{makemodels}
 
1408  $::form->{translations}
 
1410  $::form->{assemblies}
 
1411  $::form->{assortments}
 
1413 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1415 =item C<recalc_item_totals %params>
 
1417 Helper function for calculating the total lastcost and sellprice for assemblies
 
1418 or assortments according to their items, which are parsed from the current
 
1421 Is called whenever the qty of an item is changed or items are deleted.
 
1425 * part_type : 'assortment' or 'assembly' (mandatory)
 
1427 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1429 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1431 Doesn't work for recursive items.
 
1435 =head1 GET SET INITS
 
1437 There are get_set_inits for
 
1445 which parse $::form and automatically create an array of objects.
 
1447 These inits are used during saving and each time a new element is added.
 
1451 =item C<init_makemodels>
 
1453 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1454 $self->part->makemodels, ready to be saved.
 
1456 Used for saving parts and adding new makemodel rows.
 
1458 =item C<parse_add_items_to_objects PART_TYPE>
 
1460 Parses the resulting form from either the part-picker submit or the multi-item
 
1461 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1462 can be rendered via C<render_assortment_items_to_html> or
 
1463 C<render_assembly_items_to_html>.
 
1465 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1466 Optional param: position (used for numbering and listrow class)
 
1468 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1470 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1471 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1472 assortment items are added.
 
1474 =item C<parse_form_makemodels>
 
1476 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1477 remembers when the lastcost for that vendor changed the last time.
 
1479 So the original values are cloned and remembered, so we can compare if lastcost
 
1480 was changed in $::form, and keep or update lastupdate.
 
1482 lastcost isn't updated until the first time it was saved with a value, until
 
1485 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1486 makemodel entries exist or not.
 
1488 We still need init_makemodels for when we open the part for editing.
 
1498 It should be possible to jump to the edit page in a specific tab
 
1502 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1503 back to the order again afterwards.
 
1507 Support units when adding assembly items or assortment items. Currently the
 
1508 default unit of the item is always used.
 
1512 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1513 consists of other assemblies.
 
1519 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>