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;
 
  19 use Rose::Object::MakeMethods::Generic (
 
  20   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
 
  23                                   assortment assortment_items assembly assembly_items
 
  24                                   all_pricegroups all_translations all_partsgroups all_units
 
  25                                   all_buchungsgruppen all_payment_terms all_warehouses
 
  26                                   all_languages all_units all_price_factors) ],
 
  27   'scalar'                => [ qw(warehouse bin) ],
 
  31 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
 
  32                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
  34 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
 
  36 # actions for editing parts
 
  39   my ($self, %params) = @_;
 
  41   $self->part( SL::DB::Part->new_part );
 
  45 sub action_add_service {
 
  46   my ($self, %params) = @_;
 
  48   $self->part( SL::DB::Part->new_service );
 
  52 sub action_add_assembly {
 
  53   my ($self, %params) = @_;
 
  55   $self->part( SL::DB::Part->new_assembly );
 
  59 sub action_add_assortment {
 
  60   my ($self, %params) = @_;
 
  62   $self->part( SL::DB::Part->new_assortment );
 
  69   check_has_valid_part_type($::form->{part_type});
 
  71   $self->action_add_part       if $::form->{part_type} eq 'part';
 
  72   $self->action_add_service    if $::form->{part_type} eq 'service';
 
  73   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
 
  74   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
 
  78   my ($self, %params) = @_;
 
  80   # checks that depend only on submitted $::form
 
  81   $self->check_form or return $self->js->render;
 
  83   my $is_new = !$self->part->id; # $ part gets loaded here
 
  85   # check that the part hasn't been modified
 
  87     $self->check_part_not_modified or
 
  88       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;
 
  91   if ( $is_new and !$::form->{part}{partnumber} ) {
 
  92     $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
 
  97   my @errors = $self->part->validate;
 
  98   return $self->js->error(@errors)->render if @errors;
 
 100   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
 
 101   $self->part->db->with_transaction(sub {
 
 103     if ( $params{save_as_new} ) {
 
 104       $self->part( $self->part->clone_and_reset_deep );
 
 105       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
 
 108     $self->part->save(cascade => 1);
 
 110     SL::DB::History->new(
 
 111       trans_id    => $self->part->id,
 
 112       snumbers    => 'partnumber_' . $self->part->partnumber,
 
 113       employee_id => SL::DB::Manager::Employee->current->id,
 
 118     CVar->save_custom_variables(
 
 119         dbh          => $self->part->db->dbh,
 
 121         trans_id     => $self->part->id,
 
 122         variables    => $::form, # $::form->{cvar} would be nicer
 
 127   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
 
 129   flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
 
 131   # reload item, this also resets last_modification!
 
 132   $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
 
 135 sub action_save_as_new {
 
 137   $self->action_save(save_as_new=>1);
 
 143   my $db = $self->part->db; # $self->part has a get_set_init on $::form
 
 145   my $partnumber = $self->part->partnumber; # remember for history log
 
 150       # delete part, together with relationships that don't already
 
 151       # have an ON DELETE CASCADE, e.g. makemodel and translation.
 
 152       $self->part->delete(cascade => 1);
 
 154       SL::DB::History->new(
 
 155         trans_id    => $self->part->id,
 
 156         snumbers    => 'partnumber_' . $partnumber,
 
 157         employee_id => SL::DB::Manager::Employee->current->id,
 
 159         addition    => 'DELETED',
 
 162   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
 
 164   flash_later('info', t8('The item has been deleted.'));
 
 165   my @redirect_params = (
 
 166     controller => 'controller.pl',
 
 167     action => 'LoginScreen/user_login'
 
 169   $self->redirect_to(@redirect_params);
 
 172 sub action_use_as_new {
 
 173   my ($self, %params) = @_;
 
 175   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
 
 176   $::form->{oldpartnumber} = $oldpart->partnumber;
 
 178   $self->part($oldpart->clone_and_reset_deep);
 
 180   $self->part->partnumber(undef);
 
 186   my ($self, %params) = @_;
 
 192   my ($self, %params) = @_;
 
 194   $self->_set_javascript;
 
 196   my (%assortment_vars, %assembly_vars);
 
 197   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
 
 198   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
 
 200   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
 202   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
 
 203     if (scalar @{ $params{CUSTOM_VARIABLES} });
 
 205   my %title_hash = ( part       => t8('Edit Part'),
 
 206                      assembly   => t8('Edit Assembly'),
 
 207                      service    => t8('Edit Service'),
 
 208                      assortment => t8('Edit Assortment'),
 
 211   $self->part->prices([])       unless $self->part->prices;
 
 212   $self->part->translations([]) unless $self->part->translations;
 
 216     title             => $title_hash{$self->part->part_type},
 
 217     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
 
 220     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
 
 221     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
 
 222     oldpartnumber     => $::form->{oldpartnumber},
 
 223     old_id            => $::form->{old_id},
 
 231   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
 
 232   $_[0]->render('part/history', { layout => 0 },
 
 233                                   history_entries => $history_entries);
 
 236 sub action_update_item_totals {
 
 239   my $part_type = $::form->{part_type};
 
 240   die unless $part_type =~ /^(assortment|assembly)$/;
 
 242   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
 
 243   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
 
 245   my $sum_diff      = $sellprice_sum-$lastcost_sum;
 
 248     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 249     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 250     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
 
 251     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
 
 252     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
 
 256 sub action_add_multi_assortment_items {
 
 259   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 260   my $html         = $self->render_assortment_items_to_html($item_objects);
 
 262   $self->js->run('kivi.Part.close_multi_items_dialog')
 
 263            ->append('#assortment_rows', $html)
 
 264            ->run('kivi.Part.renumber_positions')
 
 265            ->run('kivi.Part.assortment_recalc')
 
 269 sub action_add_multi_assembly_items {
 
 272   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 273   my $html         = $self->render_assembly_items_to_html($item_objects);
 
 275   $self->js->run('kivi.Part.close_multi_items_dialog')
 
 276            ->append('#assembly_rows', $html)
 
 277            ->run('kivi.Part.renumber_positions')
 
 278            ->run('kivi.Part.assembly_recalc')
 
 282 sub action_add_assortment_item {
 
 283   my ($self, %params) = @_;
 
 285   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 287   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
 
 289   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 290   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
 
 291     return $self->js->flash('error', t8("This part has already been added."))->render;
 
 294   my $number_of_items = scalar @{$self->assortment_items};
 
 295   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
 
 296   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
 
 298   push(@{$self->assortment_items}, @{$item_objects});
 
 299   my $part = SL::DB::Part->new(part_type => 'assortment');
 
 300   $part->assortment_items(@{$self->assortment_items});
 
 301   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 302   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 303   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 306     ->append('#assortment_rows'        , $html)  # append in tbody
 
 307     ->val('.add_assortment_item_input' , '')
 
 308     ->run('kivi.Part.focus_last_assortment_input')
 
 309     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 310     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 311     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
 
 312     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 313     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
 
 316 sub action_add_assembly_item {
 
 319   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
 
 321   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
 
 323   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
 
 324   my $duplicate_warning = 0; # duplicates are allowed, just warn
 
 325   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
 
 326     $duplicate_warning++;
 
 329   my $number_of_items = scalar @{$self->assembly_items};
 
 330   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
 
 331   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
 
 333   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
 
 335   push(@{$self->assembly_items}, @{$item_objects});
 
 336   my $part = SL::DB::Part->new(part_type => 'assembly');
 
 337   $part->assemblies(@{$self->assembly_items});
 
 338   my $items_sellprice_sum = $part->items_sellprice_sum;
 
 339   my $items_lastcost_sum  = $part->items_lastcost_sum;
 
 340   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
 
 343     ->append('#assembly_rows', $html)  # append in tbody
 
 344     ->val('.add_assembly_item_input' , '')
 
 345     ->run('kivi.Part.focus_last_assembly_input')
 
 346     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 347     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 348     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
 
 349     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
 
 350     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
 
 354 sub action_show_multi_items_dialog {
 
 355   require SL::DB::PartsGroup;
 
 356   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
 
 357                 part_type => 'assortment',
 
 358                 partfilter => '', # can I get at the current input of the partpicker here?
 
 359                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
 
 362 sub action_multi_items_update_result {
 
 365   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 367   my $count = $_[0]->multi_items_models->count;
 
 370     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 371     $_[0]->render($text, { layout => 0 });
 
 372   } elsif ($count > $max_count) {
 
 373     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 374     $_[0]->render($text, { layout => 0 });
 
 376     my $multi_items = $_[0]->multi_items_models->get;
 
 377     $_[0]->render('part/_multi_items_result', { layout => 0 },
 
 378                   multi_items => $multi_items);
 
 382 sub action_add_makemodel_row {
 
 385   my $vendor_id = $::form->{add_makemodel};
 
 387   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
 
 388     return $self->js->error(t8("No vendor selected or found!"))->render;
 
 390   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
 
 391     $self->js->flash('info', t8("This vendor has already been added."));
 
 394   my $position = scalar @{$self->makemodels} + 1;
 
 396   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
 
 400                                   sortorder    => $position,
 
 401                                  ) or die "Can't create MakeModel object";
 
 403   my $row_as_html = $self->p->render('part/_makemodel_row',
 
 405                                      listrow   => $position % 2 ? 0 : 1,
 
 408   # after selection focus on the model field in the row that was just added
 
 410     ->append('#makemodel_rows', $row_as_html)  # append in tbody
 
 411     ->val('.add_makemodel_input', '')
 
 412     ->run('kivi.Part.focus_last_makemodel_input')
 
 416 sub action_reorder_items {
 
 419   my $part_type = $::form->{part_type};
 
 422     partnumber  => sub { $_[0]->part->partnumber },
 
 423     description => sub { $_[0]->part->description },
 
 424     qty         => sub { $_[0]->qty },
 
 425     sellprice   => sub { $_[0]->part->sellprice },
 
 426     lastcost    => sub { $_[0]->part->lastcost },
 
 427     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
 
 430   my $method = $sort_keys{$::form->{order_by}};
 
 433   if ($part_type eq 'assortment') {
 
 434     @items = @{ $self->assortment_items };
 
 436     @items = @{ $self->assembly_items };
 
 439   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
 
 440   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
 
 441     if ($::form->{sort_dir}) {
 
 442       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 444       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 447     if ($::form->{sort_dir}) {
 
 448       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 450       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 454   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
 
 457 sub action_warehouse_changed {
 
 460   $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
 
 461   die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
 
 463   if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
 
 464     $self->bin($self->warehouse->bins->[0]);
 
 466       ->html('#bin', $self->build_bin_select)
 
 467       ->focus('#part_bin_id');
 
 469     # no warehouse was selected, empty the bin field and reset the id
 
 471         ->val('#part_bin_id', undef)
 
 475   return $self->js->render;
 
 478 sub action_ajax_autocomplete {
 
 479   my ($self, %params) = @_;
 
 481   # if someone types something, and hits enter, assume he entered the full name.
 
 482   # if something matches, treat that as sole match
 
 483   # unfortunately get_models can't do more than one per package atm, so we d it
 
 484   # the oldfashioned way.
 
 485   if ($::form->{prefer_exact}) {
 
 487     if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
 
 490         SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
 
 492           description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
 
 493           partnumber  => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
 
 498       $self->parts($exact_matches);
 
 504      value       => $_->displayable_name,
 
 505      label       => $_->displayable_name,
 
 507      partnumber  => $_->partnumber,
 
 508      description => $_->description,
 
 509      part_type   => $_->part_type,
 
 511      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
 
 513   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
 
 515   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
 
 518 sub action_test_page {
 
 519   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
 
 522 sub action_part_picker_search {
 
 523   $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
 
 526 sub action_part_picker_result {
 
 527   $_[0]->render('part/_part_picker_result', { layout => 0 });
 
 533   if ($::request->type eq 'json') {
 
 538       $part_hash          = $self->part->as_tree;
 
 539       $part_hash->{cvars} = $self->part->cvar_as_hashref;
 
 542     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
 
 547 sub validate_add_items {
 
 548   scalar @{$::form->{add_items}};
 
 551 sub prepare_assortment_render_vars {
 
 554   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 555                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 556                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
 
 558   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 563 sub prepare_assembly_render_vars {
 
 566   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
 
 567                items_lastcost_sum  => $self->part->items_lastcost_sum,
 
 568                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
 
 570   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
 
 578   check_has_valid_part_type($self->part->part_type);
 
 580   $self->_set_javascript;
 
 582   my %title_hash = ( part       => t8('Add Part'),
 
 583                      assembly   => t8('Add Assembly'),
 
 584                      service    => t8('Add Service'),
 
 585                      assortment => t8('Add Assortment'),
 
 590     title             => $title_hash{$self->part->part_type},
 
 591     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
 
 596 sub _set_javascript {
 
 598   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
 
 599   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
 
 602 sub recalc_item_totals {
 
 603   my ($self, %params) = @_;
 
 605   if ( $params{part_type} eq 'assortment' ) {
 
 606     return 0 unless scalar @{$self->assortment_items};
 
 607   } elsif ( $params{part_type} eq 'assembly' ) {
 
 608     return 0 unless scalar @{$self->assembly_items};
 
 610     carp "can only calculate sum for assortments and assemblies";
 
 613   my $part = SL::DB::Part->new(part_type => $params{part_type});
 
 614   if ( $part->is_assortment ) {
 
 615     $part->assortment_items( @{$self->assortment_items} );
 
 616     if ( $params{price_type} eq 'lastcost' ) {
 
 617       return $part->items_lastcost_sum;
 
 619       if ( $params{pricegroup_id} ) {
 
 620         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
 
 622         return $part->items_sellprice_sum;
 
 625   } elsif ( $part->is_assembly ) {
 
 626     $part->assemblies( @{$self->assembly_items} );
 
 627     if ( $params{price_type} eq 'lastcost' ) {
 
 628       return $part->items_lastcost_sum;
 
 630       return $part->items_sellprice_sum;
 
 635 sub check_part_not_modified {
 
 638   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
 
 645   my $is_new = !$self->part->id;
 
 647   my $params = delete($::form->{part}) || { };
 
 649   delete $params->{id};
 
 650   # never overwrite existing partnumber, should be a read-only field anyway
 
 651   delete $params->{partnumber} if $self->part->partnumber;
 
 652   $self->part->assign_attributes(%{ $params});
 
 653   $self->part->bin_id(undef) unless $self->part->warehouse_id;
 
 655   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
 
 656   # will be the case for used assortments when saving, or when a used assortment
 
 658   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
 
 659     $self->part->assortment_items([]);
 
 660     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
 
 663   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
 
 664     $self->part->assemblies([]); # completely rewrite assortments each time
 
 665     $self->part->add_assemblies( @{ $self->assembly_items } );
 
 668   $self->part->translations([]);
 
 669   $self->parse_form_translations;
 
 671   $self->part->prices([]);
 
 672   $self->parse_form_prices;
 
 674   $self->parse_form_makemodels;
 
 677 sub parse_form_prices {
 
 679   # only save prices > 0
 
 680   my $prices = delete($::form->{prices}) || [];
 
 681   foreach my $price ( @{$prices} ) {
 
 682     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
 
 683     next unless $sellprice > 0; # skip negative prices as well
 
 684     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
 
 685                                pricegroup_id => $price->{pricegroup_id},
 
 688     $self->part->add_prices($p);
 
 692 sub parse_form_translations {
 
 694   # don't add empty translations
 
 695   my $translations = delete($::form->{translations}) || [];
 
 696   foreach my $translation ( @{$translations} ) {
 
 697     next unless $translation->{translation};
 
 698     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
 
 699     $self->part->add_translations( $translation );
 
 703 sub parse_form_makemodels {
 
 707   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
 
 708     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
 
 711   $self->part->makemodels([]);
 
 714   my $makemodels = delete($::form->{makemodels}) || [];
 
 715   foreach my $makemodel ( @{$makemodels} ) {
 
 716     next unless $makemodel->{make};
 
 718     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
 
 720     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 721                                      id         => $makemodel->{id},
 
 722                                      make       => $makemodel->{make},
 
 723                                      model      => $makemodel->{model} || '',
 
 724                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
 
 725                                      sortorder  => $position,
 
 727     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
 
 728       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
 
 729       # don't change lastupdate
 
 730     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
 
 731       # new makemodel, no lastcost entered, leave lastupdate empty
 
 732     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
 
 733       # lastcost hasn't changed, use original lastupdate
 
 734       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
 
 736       $mm->lastupdate(DateTime->now);
 
 738     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
 
 739     $self->part->add_makemodels($mm);
 
 743 sub build_bin_select {
 
 744   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
 
 745     title_key => 'description',
 
 746     default   => $_[0]->bin->id,
 
 750 # get_set_inits for partpicker
 
 753   if ($::form->{no_paginate}) {
 
 754     $_[0]->models->disable_plugin('paginated');
 
 760 # get_set_inits for part controller
 
 764   # used by edit, save, delete and add
 
 766   if ( $::form->{part}{id} ) {
 
 767     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
 
 769     die "part_type missing" unless $::form->{part}{part_type};
 
 770     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
 
 776   return $self->part->orphaned;
 
 782   SL::Controller::Helper::GetModels->new(
 
 789       partnumber  => t8('Partnumber'),
 
 790       description  => t8('Description'),
 
 792     with_objects => [ qw(unit_obj) ],
 
 801 sub init_assortment_items {
 
 802   # this init is used while saving and whenever assortments change dynamically
 
 806   my $assortment_items = delete($::form->{assortment_items}) || [];
 
 807   foreach my $assortment_item ( @{$assortment_items} ) {
 
 808     next unless $assortment_item->{parts_id};
 
 810     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
 
 811     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
 
 812                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
 
 813                                           charge        => $assortment_item->{charge},
 
 814                                           unit          => $assortment_item->{unit} || $part->unit,
 
 815                                           position      => $position,
 
 823 sub init_makemodels {
 
 827   my @makemodel_array = ();
 
 828   my $makemodels = delete($::form->{makemodels}) || [];
 
 830   foreach my $makemodel ( @{$makemodels} ) {
 
 831     next unless $makemodel->{make};
 
 833     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
 
 834                                     id        => $makemodel->{id},
 
 835                                     make      => $makemodel->{make},
 
 836                                     model     => $makemodel->{model} || '',
 
 837                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
 
 838                                     sortorder => $position,
 
 839                                   ) or die "Can't create mm";
 
 840     # $mm->id($makemodel->{id}) if $makemodel->{id};
 
 841     push(@makemodel_array, $mm);
 
 843   return \@makemodel_array;
 
 846 sub init_assembly_items {
 
 850   my $assembly_items = delete($::form->{assembly_items}) || [];
 
 851   foreach my $assembly_item ( @{$assembly_items} ) {
 
 852     next unless $assembly_item->{parts_id};
 
 854     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
 
 855     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
 856                                    bom         => $assembly_item->{bom},
 
 857                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
 
 858                                    position    => $position,
 
 865 sub init_all_warehouses {
 
 867   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
 
 870 sub init_all_languages {
 
 871   SL::DB::Manager::Language->get_all_sorted;
 
 874 sub init_all_partsgroups {
 
 876   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 
 879 sub init_all_buchungsgruppen {
 
 881   if ( $self->part->orphaned ) {
 
 882     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 884     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 890   if ( $self->part->orphaned ) {
 
 891     return SL::DB::Manager::Unit->get_all_sorted;
 
 893     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 897 sub init_all_payment_terms {
 
 898   SL::DB::Manager::PaymentTerm->get_all_sorted;
 
 901 sub init_all_price_factors {
 
 902   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 905 sub init_all_pricegroups {
 
 906   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 909 # model used to filter/display the parts in the multi-items dialog
 
 910 sub init_multi_items_models {
 
 911   SL::Controller::Helper::GetModels->new(
 
 914     with_objects   => [ qw(unit_obj partsgroup) ],
 
 915     disable_plugin => 'paginated',
 
 916     source         => $::form->{multi_items},
 
 922       partnumber  => t8('Partnumber'),
 
 923       description => t8('Description')}
 
 927 # simple checks to run on $::form before saving
 
 929 sub form_check_part_description_exists {
 
 932   return 1 if $::form->{part}{description};
 
 934   $self->js->flash('error', t8('Part Description missing!'))
 
 935            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 936            ->focus('#part_description');
 
 940 sub form_check_assortment_items_exist {
 
 943   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 944   # skip check for existing parts that have been used
 
 945   return 1 if ($self->part->id and !$self->part->orphaned);
 
 947   # new or orphaned parts must have items in $::form->{assortment_items}
 
 948   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
 949     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 950              ->focus('#add_assortment_item_name')
 
 951              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
 957 sub form_check_assortment_items_unique {
 
 960   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 962   my %duplicate_elements;
 
 964   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
 965     $duplicate_elements{$_}++ if $count{$_}++;
 
 968   if ( keys %duplicate_elements ) {
 
 969     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 970              ->flash('error', t8('There are duplicate assortment items'));
 
 976 sub form_check_assembly_items_exist {
 
 979   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
 981   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
 982     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
 983              ->focus('#add_assembly_item_name')
 
 984              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
 990 sub form_check_partnumber_is_unique {
 
 993   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
 994     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
 996       $self->js->flash('error', t8('The partnumber already exists!'))
 
 997                ->focus('#part_description');
 
1004 # general checking functions
 
1005 sub check_next_transnumber_is_free {
 
1008   my ($next_transnumber, $count);
 
1009   $self->part->db->with_transaction(sub {
 
1010     $next_transnumber = $self->part->get_next_trans_number;
 
1011     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
 
1014   $count ? return 0 : return 1;
 
1018   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1024   $self->form_check_part_description_exists || return 0;
 
1025   $self->form_check_assortment_items_exist  || return 0;
 
1026   $self->form_check_assortment_items_unique || return 0;
 
1027   $self->form_check_assembly_items_exist    || return 0;
 
1028   $self->form_check_partnumber_is_unique    || return 0;
 
1033 sub check_has_valid_part_type {
 
1034   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1037 sub render_assortment_items_to_html {
 
1038   my ($self, $assortment_items, $number_of_items) = @_;
 
1040   my $position = $number_of_items + 1;
 
1042   foreach my $ai (@$assortment_items) {
 
1043     $html .= $self->p->render('part/_assortment_row',
 
1044                               PART     => $self->part,
 
1045                               orphaned => $self->orphaned,
 
1047                               listrow  => $position % 2 ? 1 : 0,
 
1048                               position => $position, # for legacy assemblies
 
1055 sub render_assembly_items_to_html {
 
1056   my ($self, $assembly_items, $number_of_items) = @_;
 
1058   my $position = $number_of_items + 1;
 
1060   foreach my $ai (@{$assembly_items}) {
 
1061     $html .= $self->p->render('part/_assembly_row',
 
1062                               PART     => $self->part,
 
1063                               orphaned => $self->orphaned,
 
1065                               listrow  => $position % 2 ? 1 : 0,
 
1066                               position => $position, # for legacy assemblies
 
1073 sub parse_add_items_to_objects {
 
1074   my ($self, %params) = @_;
 
1075   my $part_type = $params{part_type};
 
1076   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1077   my $position = $params{position} || 1;
 
1079   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1082   foreach my $item ( @add_items ) {
 
1083     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1085     if ( $part_type eq 'assortment' ) {
 
1086        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1087                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1088                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1089                                          position      => $position,
 
1090                                         ) or die "Can't create AssortmentItem from item";
 
1091     } elsif ( $part_type eq 'assembly' ) {
 
1092       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1093                                  # id          => $self->assembly->id, # will be set on save
 
1094                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1095                                  bom         => 0, # default when adding: no bom
 
1096                                  position    => $position,
 
1099       die "part_type must be assortment or assembly";
 
1101     push(@item_objects, $ai);
 
1105   return \@item_objects;
 
1116 SL::Controller::Part - Part CRUD controller
 
1120 Controller for adding/editing/saving/deleting parts.
 
1122 All the relations are loaded at once and saving the part, adding a history
 
1123 entry and saving CVars happens inside one transaction.  When saving the old
 
1124 relations are deleted and written as new to the database.
 
1126 Relations for parts:
 
1134 =item assembly items
 
1136 =item assortment items
 
1144 There are 4 different part types:
 
1150 The "default" part type.
 
1152 inventory_accno_id is set.
 
1156 Services can't be stocked.
 
1158 inventory_accno_id isn't set.
 
1162 Assemblies consist of other parts, services, assemblies or assortments. They
 
1163 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1164 have to make them, which reduces the stock by its respective components. Once
 
1165 an assembly item has been created there is currently no way to "disassemble" it
 
1166 again. An assembly item can appear several times in one assembly. An assmbly is
 
1167 sold as one item with a defined sellprice and lastcost. If the component prices
 
1168 change the assortment price remains the same. The assembly items may be printed
 
1169 in a record if the item's "bom" is set.
 
1173 Similar to assembly, but each assortment item may only appear once per
 
1174 assortment. When selling an assortment the assortment items are added to the
 
1175 record together with the assortment, which is added with sellprice 0.
 
1177 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1178 determined by the sum of the current assortment item prices when the assortment
 
1179 is added to a record. This also means that price rules and customer discounts
 
1180 will be applied to the assortment items.
 
1182 Once the assortment items have been added they may be modified or deleted, just
 
1183 as if they had been added manually, the individual assortment items aren't
 
1184 linked to the assortment or the other assortment items in any way.
 
1192 =item C<action_add_part>
 
1194 =item C<action_add_service>
 
1196 =item C<action_add_assembly>
 
1198 =item C<action_add_assortment>
 
1200 =item C<action_add PART_TYPE>
 
1202 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1203 parameter part_type as an action. Example:
 
1205   controller.pl?action=Part/add&part_type=service
 
1207 =item C<action_save>
 
1209 Saves the current part and then reloads the edit page for the part.
 
1211 =item C<action_use_as_new>
 
1213 Takes the information from the current part, plus any modifications made on the
 
1214 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1215 set empty, so a new partnumber from the number range will be used if the user
 
1216 doesn't enter one manually.
 
1218 Unsaved changes to the original part aren't updated.
 
1220 The part type cannot be changed in this way.
 
1222 =item C<action_delete>
 
1224 Deletes the current part and then redirects to the main page, there is no
 
1227 The delete button only appears if the part is 'orphaned', according to
 
1228 SL::DB::Part orphaned.
 
1230 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1231 the inventory, or is part of an assembly or assortment.
 
1233 If the part is deleted its relations prices, makdemodel, assembly,
 
1234 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1236 Before this controller items that appeared in inventory didn't count as
 
1237 orphaned and could be deleted and the inventory entries were also deleted, this
 
1238 "feature" hasn't been implemented.
 
1240 =item C<action_edit part.id>
 
1242 Load and display a part for editing.
 
1244   controller.pl?action=Part/edit&part.id=12345
 
1246 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1250 =head1 BUTTON ACTIONS
 
1256 Opens a popup displaying all the history entries. Once a new history controller
 
1257 is written the button could link there instead, with the part already selected.
 
1265 =item C<action_update_item_totals>
 
1267 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1268 amount of an item changes. The sum of all sellprices and lastcosts is
 
1269 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1271 =item C<action_add_assortment_item>
 
1273 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1275 If the item already exists in the assortment the item isn't added and a Flash
 
1278 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1279 after adding each new item, add the new object to the item objects that were
 
1280 already parsed, calculate totals via a dummy part then update the row and the
 
1283 =item C<action_add_assembly_item>
 
1285 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1287 If the item already exists in the assembly a flash info is generated, but the
 
1290 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1291 after adding each new item, add the new object to the item objects that were
 
1292 already parsed, calculate totals via a dummy part then update the row and the
 
1295 =item C<action_add_multi_assortment_items>
 
1297 Parses the items to be added from the form generated by the multi input and
 
1298 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1299 assortment items are renumbered and the sums recalculated via
 
1300 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1302 =item C<action_add_multi_assembly_items>
 
1304 Parses the items to be added from the form generated by the multi input and
 
1305 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1306 assembly items are renumbered and the sums recalculated via
 
1307 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1309 =item C<action_show_multi_items_dialog>
 
1311 =item C<action_multi_items_update_result>
 
1313 =item C<action_add_makemodel_row>
 
1315 Add a new makemodel row with the vendor that was selected via the vendor
 
1318 Checks the already existing makemodels and warns if a row with that vendor
 
1319 already exists. Currently it is possible to have duplicate vendor rows.
 
1321 =item C<action_reorder_items>
 
1323 Sorts the item table for assembly or assortment items.
 
1325 =item C<action_warehouse_changed>
 
1329 =head1 ACTIONS part picker
 
1333 =item C<action_ajax_autocomplete>
 
1335 =item C<action_test_page>
 
1337 =item C<action_part_picker_search>
 
1339 =item C<action_part_picker_result>
 
1341 =item C<action_show>
 
1351 Calls some simple checks that test the submitted $::form for obvious errors.
 
1352 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1354 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1355 some cases extra actions are taken, e.g. if the part description is missing the
 
1356 basic data tab is selected and the description input field is focussed.
 
1362 =item C<form_check_part_description_exists>
 
1364 =item C<form_check_assortment_items_exist>
 
1366 =item C<form_check_assortment_items_unique>
 
1368 =item C<form_check_assembly_items_exist>
 
1370 =item C<form_check_partnumber_is_unique>
 
1374 =head1 HELPER FUNCTIONS
 
1380 When submitting the form for saving, parses the transmitted form. Expects the
 
1384  $::form->{makemodels}
 
1385  $::form->{translations}
 
1387  $::form->{assemblies}
 
1388  $::form->{assortments}
 
1390 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1392 =item C<recalc_item_totals %params>
 
1394 Helper function for calculating the total lastcost and sellprice for assemblies
 
1395 or assortments according to their items, which are parsed from the current
 
1398 Is called whenever the qty of an item is changed or items are deleted.
 
1402 * part_type : 'assortment' or 'assembly' (mandatory)
 
1404 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1406 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1408 Doesn't work for recursive items.
 
1412 =head1 GET SET INITS
 
1414 There are get_set_inits for
 
1422 which parse $::form and automatically create an array of objects.
 
1424 These inits are used during saving and each time a new element is added.
 
1428 =item C<init_makemodels>
 
1430 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1431 $self->part->makemodels, ready to be saved.
 
1433 Used for saving parts and adding new makemodel rows.
 
1435 =item C<parse_add_items_to_objects PART_TYPE>
 
1437 Parses the resulting form from either the part-picker submit or the multi-item
 
1438 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1439 can be rendered via C<render_assortment_items_to_html> or
 
1440 C<render_assembly_items_to_html>.
 
1442 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1443 Optional param: position (used for numbering and listrow class)
 
1445 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1447 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1448 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1449 assortment items are added.
 
1451 =item C<parse_form_makemodels>
 
1453 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1454 remembers when the lastcost for that vendor changed the last time.
 
1456 So the original values are cloned and remembered, so we can compare if lastcost
 
1457 was changed in $::form, and keep or update lastupdate.
 
1459 lastcost isn't updated until the first time it was saved with a value, until
 
1462 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1463 makemodel entries exist or not.
 
1465 We still need init_makemodels for when we open the part for editing.
 
1475 It should be possible to jump to the edit page in a specific tab
 
1479 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1480 back to the order again afterwards.
 
1484 Support units when adding assembly items or assortment items. Currently the
 
1485 default unit of the item is always used.
 
1489 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1490 consists of other assemblies.
 
1496 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>