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 {
 
 875   SL::DB::Manager::PartsGroup->get_all_sorted;
 
 878 sub init_all_buchungsgruppen {
 
 880   if ( $self->part->orphaned ) {
 
 881     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
 
 883     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
 
 889   if ( $self->part->orphaned ) {
 
 890     return SL::DB::Manager::Unit->get_all_sorted;
 
 892     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
 
 896 sub init_all_payment_terms {
 
 897   SL::DB::Manager::PaymentTerm->get_all_sorted;
 
 900 sub init_all_price_factors {
 
 901   SL::DB::Manager::PriceFactor->get_all_sorted;
 
 904 sub init_all_pricegroups {
 
 905   SL::DB::Manager::Pricegroup->get_all_sorted;
 
 908 # model used to filter/display the parts in the multi-items dialog
 
 909 sub init_multi_items_models {
 
 910   SL::Controller::Helper::GetModels->new(
 
 913     with_objects   => [ qw(unit_obj partsgroup) ],
 
 914     disable_plugin => 'paginated',
 
 915     source         => $::form->{multi_items},
 
 921       partnumber  => t8('Partnumber'),
 
 922       description => t8('Description')}
 
 926 # simple checks to run on $::form before saving
 
 928 sub form_check_part_description_exists {
 
 931   return 1 if $::form->{part}{description};
 
 933   $self->js->flash('error', t8('Part Description missing!'))
 
 934            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
 
 935            ->focus('#part_description');
 
 939 sub form_check_assortment_items_exist {
 
 942   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 943   # skip check for existing parts that have been used
 
 944   return 1 if ($self->part->id and !$self->part->orphaned);
 
 946   # new or orphaned parts must have items in $::form->{assortment_items}
 
 947   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
 
 948     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 949              ->focus('#add_assortment_item_name')
 
 950              ->flash('error', t8('The assortment doesn\'t have any items.'));
 
 956 sub form_check_assortment_items_unique {
 
 959   return 1 unless $::form->{part}{part_type} eq 'assortment';
 
 961   my %duplicate_elements;
 
 963   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
 
 964     $duplicate_elements{$_}++ if $count{$_}++;
 
 967   if ( keys %duplicate_elements ) {
 
 968     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
 
 969              ->flash('error', t8('There are duplicate assortment items'));
 
 975 sub form_check_assembly_items_exist {
 
 978   return 1 unless $::form->{part}->{part_type} eq 'assembly';
 
 980   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
 
 981     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
 
 982              ->focus('#add_assembly_item_name')
 
 983              ->flash('error', t8('The assembly doesn\'t have any items.'));
 
 989 sub form_check_partnumber_is_unique {
 
 992   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
 
 993     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
 
 995       $self->js->flash('error', t8('The partnumber already exists!'))
 
 996                ->focus('#part_description');
 
1003 # general checking functions
 
1004 sub check_next_transnumber_is_free {
 
1007   my ($next_transnumber, $count);
 
1008   $self->part->db->with_transaction(sub {
 
1009     $next_transnumber = $self->part->get_next_trans_number;
 
1010     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
 
1013   $count ? return 0 : return 1;
 
1017   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
 
1023   $self->form_check_part_description_exists || return 0;
 
1024   $self->form_check_assortment_items_exist  || return 0;
 
1025   $self->form_check_assortment_items_unique || return 0;
 
1026   $self->form_check_assembly_items_exist    || return 0;
 
1027   $self->form_check_partnumber_is_unique    || return 0;
 
1032 sub check_has_valid_part_type {
 
1033   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
 
1036 sub render_assortment_items_to_html {
 
1037   my ($self, $assortment_items, $number_of_items) = @_;
 
1039   my $position = $number_of_items + 1;
 
1041   foreach my $ai (@$assortment_items) {
 
1042     $html .= $self->p->render('part/_assortment_row',
 
1043                               PART     => $self->part,
 
1044                               orphaned => $self->orphaned,
 
1046                               listrow  => $position % 2 ? 1 : 0,
 
1047                               position => $position, # for legacy assemblies
 
1054 sub render_assembly_items_to_html {
 
1055   my ($self, $assembly_items, $number_of_items) = @_;
 
1057   my $position = $number_of_items + 1;
 
1059   foreach my $ai (@{$assembly_items}) {
 
1060     $html .= $self->p->render('part/_assembly_row',
 
1061                               PART     => $self->part,
 
1062                               orphaned => $self->orphaned,
 
1064                               listrow  => $position % 2 ? 1 : 0,
 
1065                               position => $position, # for legacy assemblies
 
1072 sub parse_add_items_to_objects {
 
1073   my ($self, %params) = @_;
 
1074   my $part_type = $params{part_type};
 
1075   die unless $params{part_type} =~ /^(assortment|assembly)$/;
 
1076   my $position = $params{position} || 1;
 
1078   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
 
1081   foreach my $item ( @add_items ) {
 
1082     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
 
1084     if ( $part_type eq 'assortment' ) {
 
1085        $ai = SL::DB::AssortmentItem->new(part          => $part,
 
1086                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1087                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
 
1088                                          position      => $position,
 
1089                                         ) or die "Can't create AssortmentItem from item";
 
1090     } elsif ( $part_type eq 'assembly' ) {
 
1091       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
 
1092                                  # id          => $self->assembly->id, # will be set on save
 
1093                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
 
1094                                  bom         => 0, # default when adding: no bom
 
1095                                  position    => $position,
 
1098       die "part_type must be assortment or assembly";
 
1100     push(@item_objects, $ai);
 
1104   return \@item_objects;
 
1115 SL::Controller::Part - Part CRUD controller
 
1119 Controller for adding/editing/saving/deleting parts.
 
1121 All the relations are loaded at once and saving the part, adding a history
 
1122 entry and saving CVars happens inside one transaction.  When saving the old
 
1123 relations are deleted and written as new to the database.
 
1125 Relations for parts:
 
1133 =item assembly items
 
1135 =item assortment items
 
1143 There are 4 different part types:
 
1149 The "default" part type.
 
1151 inventory_accno_id is set.
 
1155 Services can't be stocked.
 
1157 inventory_accno_id isn't set.
 
1161 Assemblies consist of other parts, services, assemblies or assortments. They
 
1162 aren't meant to be bought, only sold. To add assemblies to stock you typically
 
1163 have to make them, which reduces the stock by its respective components. Once
 
1164 an assembly item has been created there is currently no way to "disassemble" it
 
1165 again. An assembly item can appear several times in one assembly. An assmbly is
 
1166 sold as one item with a defined sellprice and lastcost. If the component prices
 
1167 change the assortment price remains the same. The assembly items may be printed
 
1168 in a record if the item's "bom" is set.
 
1172 Similar to assembly, but each assortment item may only appear once per
 
1173 assortment. When selling an assortment the assortment items are added to the
 
1174 record together with the assortment, which is added with sellprice 0.
 
1176 Technically an assortment doesn't have a sellprice, but rather the sellprice is
 
1177 determined by the sum of the current assortment item prices when the assortment
 
1178 is added to a record. This also means that price rules and customer discounts
 
1179 will be applied to the assortment items.
 
1181 Once the assortment items have been added they may be modified or deleted, just
 
1182 as if they had been added manually, the individual assortment items aren't
 
1183 linked to the assortment or the other assortment items in any way.
 
1191 =item C<action_add_part>
 
1193 =item C<action_add_service>
 
1195 =item C<action_add_assembly>
 
1197 =item C<action_add_assortment>
 
1199 =item C<action_add PART_TYPE>
 
1201 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
 
1202 parameter part_type as an action. Example:
 
1204   controller.pl?action=Part/add&part_type=service
 
1206 =item C<action_save>
 
1208 Saves the current part and then reloads the edit page for the part.
 
1210 =item C<action_use_as_new>
 
1212 Takes the information from the current part, plus any modifications made on the
 
1213 page, and creates a new edit page that is ready to be saved. The partnumber is
 
1214 set empty, so a new partnumber from the number range will be used if the user
 
1215 doesn't enter one manually.
 
1217 Unsaved changes to the original part aren't updated.
 
1219 The part type cannot be changed in this way.
 
1221 =item C<action_delete>
 
1223 Deletes the current part and then redirects to the main page, there is no
 
1226 The delete button only appears if the part is 'orphaned', according to
 
1227 SL::DB::Part orphaned.
 
1229 The part can't be deleted if it appears in invoices, orders, delivery orders,
 
1230 the inventory, or is part of an assembly or assortment.
 
1232 If the part is deleted its relations prices, makdemodel, assembly,
 
1233 assortment_items and translation are are also deleted via DELETE ON CASCADE.
 
1235 Before this controller items that appeared in inventory didn't count as
 
1236 orphaned and could be deleted and the inventory entries were also deleted, this
 
1237 "feature" hasn't been implemented.
 
1239 =item C<action_edit part.id>
 
1241 Load and display a part for editing.
 
1243   controller.pl?action=Part/edit&part.id=12345
 
1245 Passing the part id is mandatory, and the parameter is "part.id", not "id".
 
1249 =head1 BUTTON ACTIONS
 
1255 Opens a popup displaying all the history entries. Once a new history controller
 
1256 is written the button could link there instead, with the part already selected.
 
1264 =item C<action_update_item_totals>
 
1266 Is called whenever an element with the .recalc class loses focus, e.g. the qty
 
1267 amount of an item changes. The sum of all sellprices and lastcosts is
 
1268 calculated and the totals updated. Uses C<recalc_item_totals>.
 
1270 =item C<action_add_assortment_item>
 
1272 Adds a new assortment item from a part picker seleciton to the assortment item list
 
1274 If the item already exists in the assortment the item isn't added and a Flash
 
1277 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1278 after adding each new item, add the new object to the item objects that were
 
1279 already parsed, calculate totals via a dummy part then update the row and the
 
1282 =item C<action_add_assembly_item>
 
1284 Adds a new assembly item from a part picker seleciton to the assembly item list
 
1286 If the item already exists in the assembly a flash info is generated, but the
 
1289 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
 
1290 after adding each new item, add the new object to the item objects that were
 
1291 already parsed, calculate totals via a dummy part then update the row and the
 
1294 =item C<action_add_multi_assortment_items>
 
1296 Parses the items to be added from the form generated by the multi input and
 
1297 appends the html of the tr-rows to the assortment item table. Afterwards all
 
1298 assortment items are renumbered and the sums recalculated via
 
1299 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
 
1301 =item C<action_add_multi_assembly_items>
 
1303 Parses the items to be added from the form generated by the multi input and
 
1304 appends the html of the tr-rows to the assembly item table. Afterwards all
 
1305 assembly items are renumbered and the sums recalculated via
 
1306 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
 
1308 =item C<action_show_multi_items_dialog>
 
1310 =item C<action_multi_items_update_result>
 
1312 =item C<action_add_makemodel_row>
 
1314 Add a new makemodel row with the vendor that was selected via the vendor
 
1317 Checks the already existing makemodels and warns if a row with that vendor
 
1318 already exists. Currently it is possible to have duplicate vendor rows.
 
1320 =item C<action_reorder_items>
 
1322 Sorts the item table for assembly or assortment items.
 
1324 =item C<action_warehouse_changed>
 
1328 =head1 ACTIONS part picker
 
1332 =item C<action_ajax_autocomplete>
 
1334 =item C<action_test_page>
 
1336 =item C<action_part_picker_search>
 
1338 =item C<action_part_picker_result>
 
1340 =item C<action_show>
 
1350 Calls some simple checks that test the submitted $::form for obvious errors.
 
1351 Return 1 if all the tests were successfull, 0 as soon as one test fails.
 
1353 Errors from the failed tests are stored as ClientJS actions in $self->js. In
 
1354 some cases extra actions are taken, e.g. if the part description is missing the
 
1355 basic data tab is selected and the description input field is focussed.
 
1361 =item C<form_check_part_description_exists>
 
1363 =item C<form_check_assortment_items_exist>
 
1365 =item C<form_check_assortment_items_unique>
 
1367 =item C<form_check_assembly_items_exist>
 
1369 =item C<form_check_partnumber_is_unique>
 
1373 =head1 HELPER FUNCTIONS
 
1379 When submitting the form for saving, parses the transmitted form. Expects the
 
1383  $::form->{makemodels}
 
1384  $::form->{translations}
 
1386  $::form->{assemblies}
 
1387  $::form->{assortments}
 
1389 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
 
1391 =item C<recalc_item_totals %params>
 
1393 Helper function for calculating the total lastcost and sellprice for assemblies
 
1394 or assortments according to their items, which are parsed from the current
 
1397 Is called whenever the qty of an item is changed or items are deleted.
 
1401 * part_type : 'assortment' or 'assembly' (mandatory)
 
1403 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
 
1405 Depending on the price_type the lastcost sum or sellprice sum is returned.
 
1407 Doesn't work for recursive items.
 
1411 =head1 GET SET INITS
 
1413 There are get_set_inits for
 
1421 which parse $::form and automatically create an array of objects.
 
1423 These inits are used during saving and each time a new element is added.
 
1427 =item C<init_makemodels>
 
1429 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
 
1430 $self->part->makemodels, ready to be saved.
 
1432 Used for saving parts and adding new makemodel rows.
 
1434 =item C<parse_add_items_to_objects PART_TYPE>
 
1436 Parses the resulting form from either the part-picker submit or the multi-item
 
1437 submit, and creates an arrayref of assortment_item or assembly objects, that
 
1438 can be rendered via C<render_assortment_items_to_html> or
 
1439 C<render_assembly_items_to_html>.
 
1441 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
 
1442 Optional param: position (used for numbering and listrow class)
 
1444 =item C<render_assortment_items_to_html ITEM_OBJECTS>
 
1446 Takes an array_ref of assortment_items, and generates tables rows ready for
 
1447 adding to the assortment table.  Is used when a part is loaded, or whenever new
 
1448 assortment items are added.
 
1450 =item C<parse_form_makemodels>
 
1452 Makemodels can't just be overwritten, because of the field "lastupdate", that
 
1453 remembers when the lastcost for that vendor changed the last time.
 
1455 So the original values are cloned and remembered, so we can compare if lastcost
 
1456 was changed in $::form, and keep or update lastupdate.
 
1458 lastcost isn't updated until the first time it was saved with a value, until
 
1461 Also a boolean "makemodel" needs to be written in parts, depending on whether
 
1462 makemodel entries exist or not.
 
1464 We still need init_makemodels for when we open the part for editing.
 
1474 It should be possible to jump to the edit page in a specific tab
 
1478 Support callbacks, e.g. creating a new part from within an order, and jumping
 
1479 back to the order again afterwards.
 
1483 Support units when adding assembly items or assortment items. Currently the
 
1484 default unit of the item is always used.
 
1488 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
 
1489 consists of other assemblies.
 
1495 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>