1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
9 use SL::Controller::Helper::GetModels;
10 use SL::Locale::String qw(t8);
12 use List::Util qw(sum);
13 use SL::Helper::Flash;
17 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
19 use SL::MoreCommon qw(save_form);
22 use Rose::Object::MakeMethods::Generic (
23 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
26 assortment assortment_items assembly assembly_items
27 all_pricegroups all_translations all_partsgroups all_units
28 all_buchungsgruppen all_payment_terms all_warehouses
29 all_languages all_units all_price_factors) ],
30 'scalar' => [ qw(warehouse bin) ],
34 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
35 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
37 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
39 # actions for editing parts
42 my ($self, %params) = @_;
44 $::form->{callback} = $self->url_for(action => 'add_part') unless $::form->{callback};
45 $self->part( SL::DB::Part->new_part );
49 sub action_add_service {
50 my ($self, %params) = @_;
52 $::form->{callback} = $self->url_for(action => 'add_service') unless $::form->{callback};
53 $self->part( SL::DB::Part->new_service );
57 sub action_add_assembly {
58 my ($self, %params) = @_;
60 $::form->{callback} = $self->url_for(action => 'add_assembly') unless $::form->{callback};
61 $self->part( SL::DB::Part->new_assembly );
65 sub action_add_assortment {
66 my ($self, %params) = @_;
68 $::form->{callback} = $self->url_for(action => 'add_assortment') unless $::form->{callback};
69 $self->part( SL::DB::Part->new_assortment );
73 sub action_add_from_record {
76 check_has_valid_part_type($::form->{part}{part_type});
85 check_has_valid_part_type($::form->{part_type});
87 $self->action_add_part if $::form->{part_type} eq 'part';
88 $self->action_add_service if $::form->{part_type} eq 'service';
89 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
90 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
94 my ($self, %params) = @_;
96 # checks that depend only on submitted $::form
97 $self->check_form or return $self->js->render;
99 my $is_new = !$self->part->id; # $ part gets loaded here
101 # check that the part hasn't been modified
103 $self->check_part_not_modified or
104 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;
108 && $::form->{part}{partnumber}
109 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
111 return $self->js->error(t8('The partnumber is already being used'))->render;
116 my @errors = $self->part->validate;
117 return $self->js->error(@errors)->render if @errors;
119 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
120 $self->part->db->with_transaction(sub {
122 if ( $params{save_as_new} ) {
123 $self->part( $self->part->clone_and_reset_deep );
124 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
127 $self->part->save(cascade => 1);
129 SL::DB::History->new(
130 trans_id => $self->part->id,
131 snumbers => 'partnumber_' . $self->part->partnumber,
132 employee_id => SL::DB::Manager::Employee->current->id,
137 CVar->save_custom_variables(
138 dbh => $self->part->db->dbh,
140 trans_id => $self->part->id,
141 variables => $::form, # $::form->{cvar} would be nicer
146 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
149 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
151 if ( $::form->{callback} ) {
152 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
155 # default behaviour after save: reload item, this also resets last_modification!
156 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
160 sub action_save_as_new {
162 $self->action_save(save_as_new=>1);
168 my $db = $self->part->db; # $self->part has a get_set_init on $::form
170 my $partnumber = $self->part->partnumber; # remember for history log
175 # delete part, together with relationships that don't already
176 # have an ON DELETE CASCADE, e.g. makemodel and translation.
177 $self->part->delete(cascade => 1);
179 SL::DB::History->new(
180 trans_id => $self->part->id,
181 snumbers => 'partnumber_' . $partnumber,
182 employee_id => SL::DB::Manager::Employee->current->id,
184 addition => 'DELETED',
187 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
189 flash_later('info', t8('The item has been deleted.'));
190 if ( $::form->{callback} ) {
191 $self->redirect_to($::form->unescape($::form->{callback}));
193 my @redirect_params = (
194 controller => 'controller.pl',
195 action => 'LoginScreen/user_login'
197 $self->redirect_to(@redirect_params);
201 sub action_use_as_new {
202 my ($self, %params) = @_;
204 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
205 $::form->{oldpartnumber} = $oldpart->partnumber;
207 $self->part($oldpart->clone_and_reset_deep);
209 $self->part->partnumber(undef);
215 my ($self, %params) = @_;
221 my ($self, %params) = @_;
223 $self->_set_javascript;
224 $self->_setup_form_action_bar;
226 my (%assortment_vars, %assembly_vars);
227 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
228 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
230 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
232 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
233 if (scalar @{ $params{CUSTOM_VARIABLES} });
235 my %title_hash = ( part => t8('Edit Part'),
236 assembly => t8('Edit Assembly'),
237 service => t8('Edit Service'),
238 assortment => t8('Edit Assortment'),
241 $self->part->prices([]) unless $self->part->prices;
242 $self->part->translations([]) unless $self->part->translations;
246 title => $title_hash{$self->part->part_type},
249 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
250 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
251 oldpartnumber => $::form->{oldpartnumber},
252 old_id => $::form->{old_id},
260 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
261 $_[0]->render('part/history', { layout => 0 },
262 history_entries => $history_entries);
265 sub action_update_item_totals {
268 my $part_type = $::form->{part_type};
269 die unless $part_type =~ /^(assortment|assembly)$/;
271 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
272 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
274 my $sum_diff = $sellprice_sum-$lastcost_sum;
277 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
278 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
279 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
280 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
281 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
282 ->no_flash_clear->render();
285 sub action_add_multi_assortment_items {
288 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
289 my $html = $self->render_assortment_items_to_html($item_objects);
291 $self->js->run('kivi.Part.close_picker_dialogs')
292 ->append('#assortment_rows', $html)
293 ->run('kivi.Part.renumber_positions')
294 ->run('kivi.Part.assortment_recalc')
298 sub action_add_multi_assembly_items {
301 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
303 foreach my $item (@{$item_objects}) {
304 my $errstr = validate_assembly($item->part,$self->part);
305 $self->js->flash('error',$errstr) if $errstr;
306 push (@checked_objects,$item) unless $errstr;
309 my $html = $self->render_assembly_items_to_html(\@checked_objects);
311 $self->js->run('kivi.Part.close_picker_dialogs')
312 ->append('#assembly_rows', $html)
313 ->run('kivi.Part.renumber_positions')
314 ->run('kivi.Part.assembly_recalc')
318 sub action_add_assortment_item {
319 my ($self, %params) = @_;
321 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
323 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
325 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
326 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
327 return $self->js->flash('error', t8("This part has already been added."))->render;
330 my $number_of_items = scalar @{$self->assortment_items};
331 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
332 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
334 push(@{$self->assortment_items}, @{$item_objects});
335 my $part = SL::DB::Part->new(part_type => 'assortment');
336 $part->assortment_items(@{$self->assortment_items});
337 my $items_sellprice_sum = $part->items_sellprice_sum;
338 my $items_lastcost_sum = $part->items_lastcost_sum;
339 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
342 ->append('#assortment_rows' , $html) # append in tbody
343 ->val('.add_assortment_item_input' , '')
344 ->run('kivi.Part.focus_last_assortment_input')
345 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
346 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
347 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
348 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
349 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
353 sub action_add_assembly_item {
356 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
358 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
360 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
362 my $duplicate_warning = 0; # duplicates are allowed, just warn
363 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
364 $duplicate_warning++;
367 my $number_of_items = scalar @{$self->assembly_items};
368 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
370 foreach my $item (@{$item_objects}) {
371 my $errstr = validate_assembly($item->part,$self->part);
372 return $self->js->flash('error',$errstr)->render if $errstr;
377 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
379 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
381 push(@{$self->assembly_items}, @{$item_objects});
382 my $part = SL::DB::Part->new(part_type => 'assembly');
383 $part->assemblies(@{$self->assembly_items});
384 my $items_sellprice_sum = $part->items_sellprice_sum;
385 my $items_lastcost_sum = $part->items_lastcost_sum;
386 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
389 ->append('#assembly_rows', $html) # append in tbody
390 ->val('.add_assembly_item_input' , '')
391 ->run('kivi.Part.focus_last_assembly_input')
392 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
393 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
394 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
395 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
396 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
400 sub action_show_multi_items_dialog {
401 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
402 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
406 sub action_multi_items_update_result {
409 $::form->{multi_items}->{filter}->{obsolete} = 0;
411 my $count = $_[0]->multi_items_models->count;
414 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
415 $_[0]->render($text, { layout => 0 });
416 } elsif ($count > $max_count) {
417 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
418 $_[0]->render($text, { layout => 0 });
420 my $multi_items = $_[0]->multi_items_models->get;
421 $_[0]->render('part/_multi_items_result', { layout => 0 },
422 multi_items => $multi_items);
426 sub action_add_makemodel_row {
429 my $vendor_id = $::form->{add_makemodel};
431 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
432 return $self->js->error(t8("No vendor selected or found!"))->render;
434 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
435 $self->js->flash('info', t8("This vendor has already been added."));
438 my $position = scalar @{$self->makemodels} + 1;
440 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
444 sortorder => $position,
445 ) or die "Can't create MakeModel object";
447 my $row_as_html = $self->p->render('part/_makemodel_row',
449 listrow => $position % 2 ? 0 : 1,
452 # after selection focus on the model field in the row that was just added
454 ->append('#makemodel_rows', $row_as_html) # append in tbody
455 ->val('.add_makemodel_input', '')
456 ->run('kivi.Part.focus_last_makemodel_input')
460 sub action_reorder_items {
463 my $part_type = $::form->{part_type};
466 partnumber => sub { $_[0]->part->partnumber },
467 description => sub { $_[0]->part->description },
468 qty => sub { $_[0]->qty },
469 sellprice => sub { $_[0]->part->sellprice },
470 lastcost => sub { $_[0]->part->lastcost },
471 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
474 my $method = $sort_keys{$::form->{order_by}};
477 if ($part_type eq 'assortment') {
478 @items = @{ $self->assortment_items };
480 @items = @{ $self->assembly_items };
483 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
484 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
485 if ($::form->{sort_dir}) {
486 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
488 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
491 if ($::form->{sort_dir}) {
492 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
494 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
498 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
501 sub action_warehouse_changed {
504 if ($::form->{warehouse_id} ) {
505 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
506 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
508 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
509 $self->bin($self->warehouse->bins->[0]);
511 ->html('#bin', $self->build_bin_select)
512 ->focus('#part_bin_id');
513 return $self->js->render;
517 # no warehouse was selected, empty the bin field and reset the id
519 ->val('#part_bin_id', undef)
522 return $self->js->render;
525 sub action_ajax_autocomplete {
526 my ($self, %params) = @_;
528 # if someone types something, and hits enter, assume he entered the full name.
529 # if something matches, treat that as sole match
530 # since we need a second get models instance with different filters for that,
531 # we only modify the original filter temporarily in place
532 if ($::form->{prefer_exact}) {
533 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
535 my $exact_models = SL::Controller::Helper::GetModels->new(
538 paginated => { per_page => 2 },
539 with_objects => [ qw(unit_obj classification) ],
542 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
543 $self->parts($exact_matches);
549 value => $_->displayable_name,
550 label => $_->displayable_name,
552 partnumber => $_->partnumber,
553 description => $_->description,
554 part_type => $_->part_type,
556 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
558 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
560 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
563 sub action_test_page {
564 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
567 sub action_part_picker_search {
568 $_[0]->render('part/part_picker_search', { layout => 0 });
571 sub action_part_picker_result {
572 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
578 if ($::request->type eq 'json') {
583 $part_hash = $self->part->as_tree;
584 $part_hash->{cvars} = $self->part->cvar_as_hashref;
587 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
592 sub validate_add_items {
593 scalar @{$::form->{add_items}};
596 sub prepare_assortment_render_vars {
599 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
600 items_lastcost_sum => $self->part->items_lastcost_sum,
601 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
603 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
608 sub prepare_assembly_render_vars {
611 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
612 items_lastcost_sum => $self->part->items_lastcost_sum,
613 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
615 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
623 check_has_valid_part_type($self->part->part_type);
625 $self->_set_javascript;
626 $self->_setup_form_action_bar;
628 my %title_hash = ( part => t8('Add Part'),
629 assembly => t8('Add Assembly'),
630 service => t8('Add Service'),
631 assortment => t8('Add Assortment'),
636 title => $title_hash{$self->part->part_type},
641 sub _set_javascript {
643 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
644 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
647 sub recalc_item_totals {
648 my ($self, %params) = @_;
650 if ( $params{part_type} eq 'assortment' ) {
651 return 0 unless scalar @{$self->assortment_items};
652 } elsif ( $params{part_type} eq 'assembly' ) {
653 return 0 unless scalar @{$self->assembly_items};
655 carp "can only calculate sum for assortments and assemblies";
658 my $part = SL::DB::Part->new(part_type => $params{part_type});
659 if ( $part->is_assortment ) {
660 $part->assortment_items( @{$self->assortment_items} );
661 if ( $params{price_type} eq 'lastcost' ) {
662 return $part->items_lastcost_sum;
664 if ( $params{pricegroup_id} ) {
665 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
667 return $part->items_sellprice_sum;
670 } elsif ( $part->is_assembly ) {
671 $part->assemblies( @{$self->assembly_items} );
672 if ( $params{price_type} eq 'lastcost' ) {
673 return $part->items_lastcost_sum;
675 return $part->items_sellprice_sum;
680 sub check_part_not_modified {
683 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
690 my $is_new = !$self->part->id;
692 my $params = delete($::form->{part}) || { };
694 delete $params->{id};
695 $self->part->assign_attributes(%{ $params});
696 $self->part->bin_id(undef) unless $self->part->warehouse_id;
698 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
699 # will be the case for used assortments when saving, or when a used assortment
701 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
702 $self->part->assortment_items([]);
703 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
706 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
707 $self->part->assemblies([]); # completely rewrite assortments each time
708 $self->part->add_assemblies( @{ $self->assembly_items } );
711 $self->part->translations([]);
712 $self->parse_form_translations;
714 $self->part->prices([]);
715 $self->parse_form_prices;
717 $self->parse_form_makemodels;
720 sub parse_form_prices {
722 # only save prices > 0
723 my $prices = delete($::form->{prices}) || [];
724 foreach my $price ( @{$prices} ) {
725 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
726 next unless $sellprice > 0; # skip negative prices as well
727 my $p = SL::DB::Price->new(parts_id => $self->part->id,
728 pricegroup_id => $price->{pricegroup_id},
731 $self->part->add_prices($p);
735 sub parse_form_translations {
737 # don't add empty translations
738 my $translations = delete($::form->{translations}) || [];
739 foreach my $translation ( @{$translations} ) {
740 next unless $translation->{translation};
741 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
742 $self->part->add_translations( $translation );
746 sub parse_form_makemodels {
750 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
751 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
754 $self->part->makemodels([]);
757 my $makemodels = delete($::form->{makemodels}) || [];
758 foreach my $makemodel ( @{$makemodels} ) {
759 next unless $makemodel->{make};
761 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
763 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
764 id => $makemodel->{id},
765 make => $makemodel->{make},
766 model => $makemodel->{model} || '',
767 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
768 sortorder => $position,
770 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
771 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
772 # don't change lastupdate
773 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
774 # new makemodel, no lastcost entered, leave lastupdate empty
775 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
776 # lastcost hasn't changed, use original lastupdate
777 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
779 $mm->lastupdate(DateTime->now);
781 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
782 $self->part->add_makemodels($mm);
786 sub build_bin_select {
787 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
788 title_key => 'description',
789 default => $_[0]->bin->id,
793 # get_set_inits for partpicker
796 if ($::form->{no_paginate}) {
797 $_[0]->models->disable_plugin('paginated');
803 # get_set_inits for part controller
807 # used by edit, save, delete and add
809 if ( $::form->{part}{id} ) {
810 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
812 die "part_type missing" unless $::form->{part}{part_type};
813 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
819 return $self->part->orphaned;
825 SL::Controller::Helper::GetModels->new(
832 partnumber => t8('Partnumber'),
833 description => t8('Description'),
835 with_objects => [ qw(unit_obj classification) ],
844 sub init_assortment_items {
845 # this init is used while saving and whenever assortments change dynamically
849 my $assortment_items = delete($::form->{assortment_items}) || [];
850 foreach my $assortment_item ( @{$assortment_items} ) {
851 next unless $assortment_item->{parts_id};
853 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
854 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
855 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
856 charge => $assortment_item->{charge},
857 unit => $assortment_item->{unit} || $part->unit,
858 position => $position,
866 sub init_makemodels {
870 my @makemodel_array = ();
871 my $makemodels = delete($::form->{makemodels}) || [];
873 foreach my $makemodel ( @{$makemodels} ) {
874 next unless $makemodel->{make};
876 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
877 id => $makemodel->{id},
878 make => $makemodel->{make},
879 model => $makemodel->{model} || '',
880 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
881 sortorder => $position,
882 ) or die "Can't create mm";
883 # $mm->id($makemodel->{id}) if $makemodel->{id};
884 push(@makemodel_array, $mm);
886 return \@makemodel_array;
889 sub init_assembly_items {
893 my $assembly_items = delete($::form->{assembly_items}) || [];
894 foreach my $assembly_item ( @{$assembly_items} ) {
895 next unless $assembly_item->{parts_id};
897 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
898 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
899 bom => $assembly_item->{bom},
900 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
901 position => $position,
908 sub init_all_warehouses {
910 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
913 sub init_all_languages {
914 SL::DB::Manager::Language->get_all_sorted;
917 sub init_all_partsgroups {
919 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
922 sub init_all_buchungsgruppen {
924 if ( $self->part->orphaned ) {
925 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
927 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
933 if ( $self->part->orphaned ) {
934 return SL::DB::Manager::Unit->get_all_sorted;
936 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
940 sub init_all_payment_terms {
942 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
945 sub init_all_price_factors {
946 SL::DB::Manager::PriceFactor->get_all_sorted;
949 sub init_all_pricegroups {
950 SL::DB::Manager::Pricegroup->get_all_sorted;
953 # model used to filter/display the parts in the multi-items dialog
954 sub init_multi_items_models {
955 SL::Controller::Helper::GetModels->new(
958 with_objects => [ qw(unit_obj partsgroup classification) ],
959 disable_plugin => 'paginated',
960 source => $::form->{multi_items},
966 partnumber => t8('Partnumber'),
967 description => t8('Description')}
971 # simple checks to run on $::form before saving
973 sub form_check_part_description_exists {
976 return 1 if $::form->{part}{description};
978 $self->js->flash('error', t8('Part Description missing!'))
979 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
980 ->focus('#part_description');
984 sub form_check_assortment_items_exist {
987 return 1 unless $::form->{part}{part_type} eq 'assortment';
988 # skip item check for existing assortments that have been used
989 return 1 if ($self->part->id and !$self->part->orphaned);
991 # new or orphaned parts must have items in $::form->{assortment_items}
992 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
993 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
994 ->focus('#add_assortment_item_name')
995 ->flash('error', t8('The assortment doesn\'t have any items.'));
1001 sub form_check_assortment_items_unique {
1004 return 1 unless $::form->{part}{part_type} eq 'assortment';
1006 my %duplicate_elements;
1008 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1009 $duplicate_elements{$_}++ if $count{$_}++;
1012 if ( keys %duplicate_elements ) {
1013 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1014 ->flash('error', t8('There are duplicate assortment items'));
1020 sub form_check_assembly_items_exist {
1023 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1025 # skip item check for existing assembly that have been used
1026 return 1 if ($self->part->id and !$self->part->orphaned);
1028 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1029 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1030 ->focus('#add_assembly_item_name')
1031 ->flash('error', t8('The assembly doesn\'t have any items.'));
1037 sub form_check_partnumber_is_unique {
1040 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1041 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1043 $self->js->flash('error', t8('The partnumber already exists!'))
1044 ->focus('#part_description');
1051 # general checking functions
1054 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1060 $self->form_check_part_description_exists || return 0;
1061 $self->form_check_assortment_items_exist || return 0;
1062 $self->form_check_assortment_items_unique || return 0;
1063 $self->form_check_assembly_items_exist || return 0;
1064 $self->form_check_partnumber_is_unique || return 0;
1069 sub check_has_valid_part_type {
1070 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1073 sub render_assortment_items_to_html {
1074 my ($self, $assortment_items, $number_of_items) = @_;
1076 my $position = $number_of_items + 1;
1078 foreach my $ai (@$assortment_items) {
1079 $html .= $self->p->render('part/_assortment_row',
1080 PART => $self->part,
1081 orphaned => $self->orphaned,
1083 listrow => $position % 2 ? 1 : 0,
1084 position => $position, # for legacy assemblies
1091 sub render_assembly_items_to_html {
1092 my ($self, $assembly_items, $number_of_items) = @_;
1094 my $position = $number_of_items + 1;
1096 foreach my $ai (@{$assembly_items}) {
1097 $html .= $self->p->render('part/_assembly_row',
1098 PART => $self->part,
1099 orphaned => $self->orphaned,
1101 listrow => $position % 2 ? 1 : 0,
1102 position => $position, # for legacy assemblies
1109 sub parse_add_items_to_objects {
1110 my ($self, %params) = @_;
1111 my $part_type = $params{part_type};
1112 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1113 my $position = $params{position} || 1;
1115 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1118 foreach my $item ( @add_items ) {
1119 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1121 if ( $part_type eq 'assortment' ) {
1122 $ai = SL::DB::AssortmentItem->new(part => $part,
1123 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1124 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1125 position => $position,
1126 ) or die "Can't create AssortmentItem from item";
1127 } elsif ( $part_type eq 'assembly' ) {
1128 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1129 # id => $self->assembly->id, # will be set on save
1130 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1131 bom => 0, # default when adding: no bom
1132 position => $position,
1135 die "part_type must be assortment or assembly";
1137 push(@item_objects, $ai);
1141 return \@item_objects;
1144 sub _setup_form_action_bar {
1147 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1149 for my $bar ($::request->layout->get('actionbar')) {
1154 call => [ 'kivi.Part.save' ],
1155 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1156 accesskey => 'enter',
1160 call => [ 'kivi.Part.use_as_new' ],
1161 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1162 : !$may_edit ? t8('You do not have the permissions to access this function.')
1165 ], # end of combobox "Save"
1169 call => [ 'kivi.Part.delete' ],
1170 confirm => t8('Do you really want to delete this object?'),
1171 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1172 : !$may_edit ? t8('You do not have the permissions to access this function.')
1173 : !$self->part->orphaned ? t8('This object has already been used.')
1181 call => [ 'kivi.Part.open_history_popup' ],
1182 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1183 : !$may_edit ? t8('You do not have the permissions to access this function.')
1198 SL::Controller::Part - Part CRUD controller
1202 Controller for adding/editing/saving/deleting parts.
1204 All the relations are loaded at once and saving the part, adding a history
1205 entry and saving CVars happens inside one transaction. When saving the old
1206 relations are deleted and written as new to the database.
1208 Relations for parts:
1216 =item assembly items
1218 =item assortment items
1226 There are 4 different part types:
1232 The "default" part type.
1234 inventory_accno_id is set.
1238 Services can't be stocked.
1240 inventory_accno_id isn't set.
1244 Assemblies consist of other parts, services, assemblies or assortments. They
1245 aren't meant to be bought, only sold. To add assemblies to stock you typically
1246 have to make them, which reduces the stock by its respective components. Once
1247 an assembly item has been created there is currently no way to "disassemble" it
1248 again. An assembly item can appear several times in one assembly. An assmbly is
1249 sold as one item with a defined sellprice and lastcost. If the component prices
1250 change the assortment price remains the same. The assembly items may be printed
1251 in a record if the item's "bom" is set.
1255 Similar to assembly, but each assortment item may only appear once per
1256 assortment. When selling an assortment the assortment items are added to the
1257 record together with the assortment, which is added with sellprice 0.
1259 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1260 determined by the sum of the current assortment item prices when the assortment
1261 is added to a record. This also means that price rules and customer discounts
1262 will be applied to the assortment items.
1264 Once the assortment items have been added they may be modified or deleted, just
1265 as if they had been added manually, the individual assortment items aren't
1266 linked to the assortment or the other assortment items in any way.
1274 =item C<action_add_part>
1276 =item C<action_add_service>
1278 =item C<action_add_assembly>
1280 =item C<action_add_assortment>
1282 =item C<action_add PART_TYPE>
1284 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1285 parameter part_type as an action. Example:
1287 controller.pl?action=Part/add&part_type=service
1289 =item C<action_save>
1291 Saves the current part and then reloads the edit page for the part.
1293 =item C<action_use_as_new>
1295 Takes the information from the current part, plus any modifications made on the
1296 page, and creates a new edit page that is ready to be saved. The partnumber is
1297 set empty, so a new partnumber from the number range will be used if the user
1298 doesn't enter one manually.
1300 Unsaved changes to the original part aren't updated.
1302 The part type cannot be changed in this way.
1304 =item C<action_delete>
1306 Deletes the current part and then redirects to the main page, there is no
1309 The delete button only appears if the part is 'orphaned', according to
1310 SL::DB::Part orphaned.
1312 The part can't be deleted if it appears in invoices, orders, delivery orders,
1313 the inventory, or is part of an assembly or assortment.
1315 If the part is deleted its relations prices, makdemodel, assembly,
1316 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1318 Before this controller items that appeared in inventory didn't count as
1319 orphaned and could be deleted and the inventory entries were also deleted, this
1320 "feature" hasn't been implemented.
1322 =item C<action_edit part.id>
1324 Load and display a part for editing.
1326 controller.pl?action=Part/edit&part.id=12345
1328 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1332 =head1 BUTTON ACTIONS
1338 Opens a popup displaying all the history entries. Once a new history controller
1339 is written the button could link there instead, with the part already selected.
1347 =item C<action_update_item_totals>
1349 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1350 amount of an item changes. The sum of all sellprices and lastcosts is
1351 calculated and the totals updated. Uses C<recalc_item_totals>.
1353 =item C<action_add_assortment_item>
1355 Adds a new assortment item from a part picker seleciton to the assortment item list
1357 If the item already exists in the assortment the item isn't added and a Flash
1360 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1361 after adding each new item, add the new object to the item objects that were
1362 already parsed, calculate totals via a dummy part then update the row and the
1365 =item C<action_add_assembly_item>
1367 Adds a new assembly item from a part picker seleciton to the assembly item list
1369 If the item already exists in the assembly a flash info is generated, but the
1372 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1373 after adding each new item, add the new object to the item objects that were
1374 already parsed, calculate totals via a dummy part then update the row and the
1377 =item C<action_add_multi_assortment_items>
1379 Parses the items to be added from the form generated by the multi input and
1380 appends the html of the tr-rows to the assortment item table. Afterwards all
1381 assortment items are renumbered and the sums recalculated via
1382 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1384 =item C<action_add_multi_assembly_items>
1386 Parses the items to be added from the form generated by the multi input and
1387 appends the html of the tr-rows to the assembly item table. Afterwards all
1388 assembly items are renumbered and the sums recalculated via
1389 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1391 =item C<action_show_multi_items_dialog>
1393 =item C<action_multi_items_update_result>
1395 =item C<action_add_makemodel_row>
1397 Add a new makemodel row with the vendor that was selected via the vendor
1400 Checks the already existing makemodels and warns if a row with that vendor
1401 already exists. Currently it is possible to have duplicate vendor rows.
1403 =item C<action_reorder_items>
1405 Sorts the item table for assembly or assortment items.
1407 =item C<action_warehouse_changed>
1411 =head1 ACTIONS part picker
1415 =item C<action_ajax_autocomplete>
1417 =item C<action_test_page>
1419 =item C<action_part_picker_search>
1421 =item C<action_part_picker_result>
1423 =item C<action_show>
1433 Calls some simple checks that test the submitted $::form for obvious errors.
1434 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1436 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1437 some cases extra actions are taken, e.g. if the part description is missing the
1438 basic data tab is selected and the description input field is focussed.
1444 =item C<form_check_part_description_exists>
1446 =item C<form_check_assortment_items_exist>
1448 =item C<form_check_assortment_items_unique>
1450 =item C<form_check_assembly_items_exist>
1452 =item C<form_check_partnumber_is_unique>
1456 =head1 HELPER FUNCTIONS
1462 When submitting the form for saving, parses the transmitted form. Expects the
1466 $::form->{makemodels}
1467 $::form->{translations}
1469 $::form->{assemblies}
1470 $::form->{assortments}
1472 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1474 =item C<recalc_item_totals %params>
1476 Helper function for calculating the total lastcost and sellprice for assemblies
1477 or assortments according to their items, which are parsed from the current
1480 Is called whenever the qty of an item is changed or items are deleted.
1484 * part_type : 'assortment' or 'assembly' (mandatory)
1486 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1488 Depending on the price_type the lastcost sum or sellprice sum is returned.
1490 Doesn't work for recursive items.
1494 =head1 GET SET INITS
1496 There are get_set_inits for
1504 which parse $::form and automatically create an array of objects.
1506 These inits are used during saving and each time a new element is added.
1510 =item C<init_makemodels>
1512 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1513 $self->part->makemodels, ready to be saved.
1515 Used for saving parts and adding new makemodel rows.
1517 =item C<parse_add_items_to_objects PART_TYPE>
1519 Parses the resulting form from either the part-picker submit or the multi-item
1520 submit, and creates an arrayref of assortment_item or assembly objects, that
1521 can be rendered via C<render_assortment_items_to_html> or
1522 C<render_assembly_items_to_html>.
1524 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1525 Optional param: position (used for numbering and listrow class)
1527 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1529 Takes an array_ref of assortment_items, and generates tables rows ready for
1530 adding to the assortment table. Is used when a part is loaded, or whenever new
1531 assortment items are added.
1533 =item C<parse_form_makemodels>
1535 Makemodels can't just be overwritten, because of the field "lastupdate", that
1536 remembers when the lastcost for that vendor changed the last time.
1538 So the original values are cloned and remembered, so we can compare if lastcost
1539 was changed in $::form, and keep or update lastupdate.
1541 lastcost isn't updated until the first time it was saved with a value, until
1544 Also a boolean "makemodel" needs to be written in parts, depending on whether
1545 makemodel entries exist or not.
1547 We still need init_makemodels for when we open the part for editing.
1557 It should be possible to jump to the edit page in a specific tab
1561 Support callbacks, e.g. creating a new part from within an order, and jumping
1562 back to the order again afterwards.
1566 Support units when adding assembly items or assortment items. Currently the
1567 default unit of the item is always used.
1571 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1572 consists of other assemblies.
1578 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>