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);
21 use Rose::Object::MakeMethods::Generic (
22 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
25 assortment assortment_items assembly assembly_items
26 all_pricegroups all_translations all_partsgroups all_units
27 all_buchungsgruppen all_payment_terms all_warehouses
28 all_languages all_units all_price_factors) ],
29 'scalar' => [ qw(warehouse bin) ],
33 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
34 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
36 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
38 # actions for editing parts
41 my ($self, %params) = @_;
43 $::form->{callback} = $self->url_for(action => 'add_part') unless $::form->{callback};
44 $self->part( SL::DB::Part->new_part );
48 sub action_add_service {
49 my ($self, %params) = @_;
51 $::form->{callback} = $self->url_for(action => 'add_service') unless $::form->{callback};
52 $self->part( SL::DB::Part->new_service );
56 sub action_add_assembly {
57 my ($self, %params) = @_;
59 $::form->{callback} = $self->url_for(action => 'add_assembly') unless $::form->{callback};
60 $self->part( SL::DB::Part->new_assembly );
64 sub action_add_assortment {
65 my ($self, %params) = @_;
67 $::form->{callback} = $self->url_for(action => 'add_assortment') unless $::form->{callback};
68 $self->part( SL::DB::Part->new_assortment );
75 check_has_valid_part_type($::form->{part_type});
77 $self->action_add_part if $::form->{part_type} eq 'part';
78 $self->action_add_service if $::form->{part_type} eq 'service';
79 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
80 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
84 my ($self, %params) = @_;
86 # checks that depend only on submitted $::form
87 $self->check_form or return $self->js->render;
89 my $is_new = !$self->part->id; # $ part gets loaded here
91 # check that the part hasn't been modified
93 $self->check_part_not_modified or
94 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;
97 if ( $is_new and !$::form->{part}{partnumber} ) {
98 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
103 my @errors = $self->part->validate;
104 return $self->js->error(@errors)->render if @errors;
106 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
107 $self->part->db->with_transaction(sub {
109 if ( $params{save_as_new} ) {
110 $self->part( $self->part->clone_and_reset_deep );
111 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
114 $self->part->save(cascade => 1);
116 SL::DB::History->new(
117 trans_id => $self->part->id,
118 snumbers => 'partnumber_' . $self->part->partnumber,
119 employee_id => SL::DB::Manager::Employee->current->id,
124 CVar->save_custom_variables(
125 dbh => $self->part->db->dbh,
127 trans_id => $self->part->id,
128 variables => $::form, # $::form->{cvar} would be nicer
133 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
136 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
138 if ( $::form->{callback} ) {
139 $self->redirect_to($::form->unescape($::form->{callback}));
141 # default behaviour after save: reload item, this also resets last_modification!
142 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
146 sub action_save_as_new {
148 $self->action_save(save_as_new=>1);
154 my $db = $self->part->db; # $self->part has a get_set_init on $::form
156 my $partnumber = $self->part->partnumber; # remember for history log
161 # delete part, together with relationships that don't already
162 # have an ON DELETE CASCADE, e.g. makemodel and translation.
163 $self->part->delete(cascade => 1);
165 SL::DB::History->new(
166 trans_id => $self->part->id,
167 snumbers => 'partnumber_' . $partnumber,
168 employee_id => SL::DB::Manager::Employee->current->id,
170 addition => 'DELETED',
173 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
175 flash_later('info', t8('The item has been deleted.'));
176 if ( $::form->{callback} ) {
177 $self->redirect_to($::form->unescape($::form->{callback}));
179 my @redirect_params = (
180 controller => 'controller.pl',
181 action => 'LoginScreen/user_login'
183 $self->redirect_to(@redirect_params);
187 sub action_use_as_new {
188 my ($self, %params) = @_;
190 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
191 $::form->{oldpartnumber} = $oldpart->partnumber;
193 $self->part($oldpart->clone_and_reset_deep);
195 $self->part->partnumber(undef);
201 my ($self, %params) = @_;
207 my ($self, %params) = @_;
209 $self->_set_javascript;
210 $self->_setup_form_action_bar;
212 my (%assortment_vars, %assembly_vars);
213 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
214 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
216 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
218 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
219 if (scalar @{ $params{CUSTOM_VARIABLES} });
221 my %title_hash = ( part => t8('Edit Part'),
222 assembly => t8('Edit Assembly'),
223 service => t8('Edit Service'),
224 assortment => t8('Edit Assortment'),
227 $self->part->prices([]) unless $self->part->prices;
228 $self->part->translations([]) unless $self->part->translations;
232 title => $title_hash{$self->part->part_type},
235 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
236 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
237 oldpartnumber => $::form->{oldpartnumber},
238 old_id => $::form->{old_id},
246 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
247 $_[0]->render('part/history', { layout => 0 },
248 history_entries => $history_entries);
251 sub action_update_item_totals {
254 my $part_type = $::form->{part_type};
255 die unless $part_type =~ /^(assortment|assembly)$/;
257 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
258 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
260 my $sum_diff = $sellprice_sum-$lastcost_sum;
263 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
264 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
265 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
266 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
267 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
268 ->no_flash_clear->render();
271 sub action_add_multi_assortment_items {
274 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
275 my $html = $self->render_assortment_items_to_html($item_objects);
277 $self->js->run('kivi.Part.close_picker_dialogs')
278 ->append('#assortment_rows', $html)
279 ->run('kivi.Part.renumber_positions')
280 ->run('kivi.Part.assortment_recalc')
284 sub action_add_multi_assembly_items {
287 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
289 foreach my $item (@{$item_objects}) {
290 my $errstr = validate_assembly($item->part,$self->part);
291 $self->js->flash('error',$errstr) if $errstr;
292 push (@checked_objects,$item) unless $errstr;
295 my $html = $self->render_assembly_items_to_html(\@checked_objects);
297 $self->js->run('kivi.Part.close_picker_dialogs')
298 ->append('#assembly_rows', $html)
299 ->run('kivi.Part.renumber_positions')
300 ->run('kivi.Part.assembly_recalc')
304 sub action_add_assortment_item {
305 my ($self, %params) = @_;
307 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
309 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
311 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
312 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
313 return $self->js->flash('error', t8("This part has already been added."))->render;
316 my $number_of_items = scalar @{$self->assortment_items};
317 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
318 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
320 push(@{$self->assortment_items}, @{$item_objects});
321 my $part = SL::DB::Part->new(part_type => 'assortment');
322 $part->assortment_items(@{$self->assortment_items});
323 my $items_sellprice_sum = $part->items_sellprice_sum;
324 my $items_lastcost_sum = $part->items_lastcost_sum;
325 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
328 ->append('#assortment_rows' , $html) # append in tbody
329 ->val('.add_assortment_item_input' , '')
330 ->run('kivi.Part.focus_last_assortment_input')
331 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
332 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
333 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
334 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
335 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
339 sub action_add_assembly_item {
342 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
344 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
346 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
348 my $duplicate_warning = 0; # duplicates are allowed, just warn
349 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
350 $duplicate_warning++;
353 my $number_of_items = scalar @{$self->assembly_items};
354 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
356 foreach my $item (@{$item_objects}) {
357 my $errstr = validate_assembly($item->part,$self->part);
358 return $self->js->flash('error',$errstr)->render if $errstr;
363 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
365 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
367 push(@{$self->assembly_items}, @{$item_objects});
368 my $part = SL::DB::Part->new(part_type => 'assembly');
369 $part->assemblies(@{$self->assembly_items});
370 my $items_sellprice_sum = $part->items_sellprice_sum;
371 my $items_lastcost_sum = $part->items_lastcost_sum;
372 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
375 ->append('#assembly_rows', $html) # append in tbody
376 ->val('.add_assembly_item_input' , '')
377 ->run('kivi.Part.focus_last_assembly_input')
378 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
379 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
380 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
381 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
382 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
386 sub action_show_multi_items_dialog {
387 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
388 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
392 sub action_multi_items_update_result {
395 $::form->{multi_items}->{filter}->{obsolete} = 0;
397 my $count = $_[0]->multi_items_models->count;
400 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
401 $_[0]->render($text, { layout => 0 });
402 } elsif ($count > $max_count) {
403 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
404 $_[0]->render($text, { layout => 0 });
406 my $multi_items = $_[0]->multi_items_models->get;
407 $_[0]->render('part/_multi_items_result', { layout => 0 },
408 multi_items => $multi_items);
412 sub action_add_makemodel_row {
415 my $vendor_id = $::form->{add_makemodel};
417 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
418 return $self->js->error(t8("No vendor selected or found!"))->render;
420 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
421 $self->js->flash('info', t8("This vendor has already been added."));
424 my $position = scalar @{$self->makemodels} + 1;
426 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
430 sortorder => $position,
431 ) or die "Can't create MakeModel object";
433 my $row_as_html = $self->p->render('part/_makemodel_row',
435 listrow => $position % 2 ? 0 : 1,
438 # after selection focus on the model field in the row that was just added
440 ->append('#makemodel_rows', $row_as_html) # append in tbody
441 ->val('.add_makemodel_input', '')
442 ->run('kivi.Part.focus_last_makemodel_input')
446 sub action_reorder_items {
449 my $part_type = $::form->{part_type};
452 partnumber => sub { $_[0]->part->partnumber },
453 description => sub { $_[0]->part->description },
454 qty => sub { $_[0]->qty },
455 sellprice => sub { $_[0]->part->sellprice },
456 lastcost => sub { $_[0]->part->lastcost },
457 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
460 my $method = $sort_keys{$::form->{order_by}};
463 if ($part_type eq 'assortment') {
464 @items = @{ $self->assortment_items };
466 @items = @{ $self->assembly_items };
469 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
470 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
471 if ($::form->{sort_dir}) {
472 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
474 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
477 if ($::form->{sort_dir}) {
478 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
480 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
484 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
487 sub action_warehouse_changed {
490 if ($::form->{warehouse_id} ) {
491 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
492 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
494 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
495 $self->bin($self->warehouse->bins->[0]);
497 ->html('#bin', $self->build_bin_select)
498 ->focus('#part_bin_id');
499 return $self->js->render;
503 # no warehouse was selected, empty the bin field and reset the id
505 ->val('#part_bin_id', undef)
508 return $self->js->render;
511 sub action_ajax_autocomplete {
512 my ($self, %params) = @_;
514 # if someone types something, and hits enter, assume he entered the full name.
515 # if something matches, treat that as sole match
516 # since we need a second get models instance with different filters for that,
517 # we only modify the original filter temporarily in place
518 if ($::form->{prefer_exact}) {
519 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
521 my $exact_models = SL::Controller::Helper::GetModels->new(
524 paginated => { per_page => 2 },
525 with_objects => [ qw(unit_obj classification) ],
528 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
529 $self->parts($exact_matches);
535 value => $_->displayable_name,
536 label => $_->displayable_name,
538 partnumber => $_->partnumber,
539 description => $_->description,
540 part_type => $_->part_type,
542 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
544 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
546 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
549 sub action_test_page {
550 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
553 sub action_part_picker_search {
554 $_[0]->render('part/part_picker_search', { layout => 0 });
557 sub action_part_picker_result {
558 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
564 if ($::request->type eq 'json') {
569 $part_hash = $self->part->as_tree;
570 $part_hash->{cvars} = $self->part->cvar_as_hashref;
573 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
578 sub validate_add_items {
579 scalar @{$::form->{add_items}};
582 sub prepare_assortment_render_vars {
585 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
586 items_lastcost_sum => $self->part->items_lastcost_sum,
587 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
589 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
594 sub prepare_assembly_render_vars {
597 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
598 items_lastcost_sum => $self->part->items_lastcost_sum,
599 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
601 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
609 check_has_valid_part_type($self->part->part_type);
611 $self->_set_javascript;
612 $self->_setup_form_action_bar;
614 my %title_hash = ( part => t8('Add Part'),
615 assembly => t8('Add Assembly'),
616 service => t8('Add Service'),
617 assortment => t8('Add Assortment'),
622 title => $title_hash{$self->part->part_type},
627 sub _set_javascript {
629 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
630 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
633 sub recalc_item_totals {
634 my ($self, %params) = @_;
636 if ( $params{part_type} eq 'assortment' ) {
637 return 0 unless scalar @{$self->assortment_items};
638 } elsif ( $params{part_type} eq 'assembly' ) {
639 return 0 unless scalar @{$self->assembly_items};
641 carp "can only calculate sum for assortments and assemblies";
644 my $part = SL::DB::Part->new(part_type => $params{part_type});
645 if ( $part->is_assortment ) {
646 $part->assortment_items( @{$self->assortment_items} );
647 if ( $params{price_type} eq 'lastcost' ) {
648 return $part->items_lastcost_sum;
650 if ( $params{pricegroup_id} ) {
651 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
653 return $part->items_sellprice_sum;
656 } elsif ( $part->is_assembly ) {
657 $part->assemblies( @{$self->assembly_items} );
658 if ( $params{price_type} eq 'lastcost' ) {
659 return $part->items_lastcost_sum;
661 return $part->items_sellprice_sum;
666 sub check_part_not_modified {
669 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
676 my $is_new = !$self->part->id;
678 my $params = delete($::form->{part}) || { };
680 delete $params->{id};
681 # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
682 delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
683 $self->part->assign_attributes(%{ $params});
684 $self->part->bin_id(undef) unless $self->part->warehouse_id;
686 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
687 # will be the case for used assortments when saving, or when a used assortment
689 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
690 $self->part->assortment_items([]);
691 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
694 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
695 $self->part->assemblies([]); # completely rewrite assortments each time
696 $self->part->add_assemblies( @{ $self->assembly_items } );
699 $self->part->translations([]);
700 $self->parse_form_translations;
702 $self->part->prices([]);
703 $self->parse_form_prices;
705 $self->parse_form_makemodels;
708 sub parse_form_prices {
710 # only save prices > 0
711 my $prices = delete($::form->{prices}) || [];
712 foreach my $price ( @{$prices} ) {
713 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
714 next unless $sellprice > 0; # skip negative prices as well
715 my $p = SL::DB::Price->new(parts_id => $self->part->id,
716 pricegroup_id => $price->{pricegroup_id},
719 $self->part->add_prices($p);
723 sub parse_form_translations {
725 # don't add empty translations
726 my $translations = delete($::form->{translations}) || [];
727 foreach my $translation ( @{$translations} ) {
728 next unless $translation->{translation};
729 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
730 $self->part->add_translations( $translation );
734 sub parse_form_makemodels {
738 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
739 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
742 $self->part->makemodels([]);
745 my $makemodels = delete($::form->{makemodels}) || [];
746 foreach my $makemodel ( @{$makemodels} ) {
747 next unless $makemodel->{make};
749 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
751 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
752 id => $makemodel->{id},
753 make => $makemodel->{make},
754 model => $makemodel->{model} || '',
755 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
756 sortorder => $position,
758 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
759 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
760 # don't change lastupdate
761 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
762 # new makemodel, no lastcost entered, leave lastupdate empty
763 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
764 # lastcost hasn't changed, use original lastupdate
765 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
767 $mm->lastupdate(DateTime->now);
769 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
770 $self->part->add_makemodels($mm);
774 sub build_bin_select {
775 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
776 title_key => 'description',
777 default => $_[0]->bin->id,
781 # get_set_inits for partpicker
784 if ($::form->{no_paginate}) {
785 $_[0]->models->disable_plugin('paginated');
791 # get_set_inits for part controller
795 # used by edit, save, delete and add
797 if ( $::form->{part}{id} ) {
798 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
800 die "part_type missing" unless $::form->{part}{part_type};
801 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
807 return $self->part->orphaned;
813 SL::Controller::Helper::GetModels->new(
820 partnumber => t8('Partnumber'),
821 description => t8('Description'),
823 with_objects => [ qw(unit_obj classification) ],
832 sub init_assortment_items {
833 # this init is used while saving and whenever assortments change dynamically
837 my $assortment_items = delete($::form->{assortment_items}) || [];
838 foreach my $assortment_item ( @{$assortment_items} ) {
839 next unless $assortment_item->{parts_id};
841 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
842 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
843 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
844 charge => $assortment_item->{charge},
845 unit => $assortment_item->{unit} || $part->unit,
846 position => $position,
854 sub init_makemodels {
858 my @makemodel_array = ();
859 my $makemodels = delete($::form->{makemodels}) || [];
861 foreach my $makemodel ( @{$makemodels} ) {
862 next unless $makemodel->{make};
864 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
865 id => $makemodel->{id},
866 make => $makemodel->{make},
867 model => $makemodel->{model} || '',
868 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
869 sortorder => $position,
870 ) or die "Can't create mm";
871 # $mm->id($makemodel->{id}) if $makemodel->{id};
872 push(@makemodel_array, $mm);
874 return \@makemodel_array;
877 sub init_assembly_items {
881 my $assembly_items = delete($::form->{assembly_items}) || [];
882 foreach my $assembly_item ( @{$assembly_items} ) {
883 next unless $assembly_item->{parts_id};
885 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
886 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
887 bom => $assembly_item->{bom},
888 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
889 position => $position,
896 sub init_all_warehouses {
898 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
901 sub init_all_languages {
902 SL::DB::Manager::Language->get_all_sorted;
905 sub init_all_partsgroups {
907 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
910 sub init_all_buchungsgruppen {
912 if ( $self->part->orphaned ) {
913 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
915 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
921 if ( $self->part->orphaned ) {
922 return SL::DB::Manager::Unit->get_all_sorted;
924 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
928 sub init_all_payment_terms {
930 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
933 sub init_all_price_factors {
934 SL::DB::Manager::PriceFactor->get_all_sorted;
937 sub init_all_pricegroups {
938 SL::DB::Manager::Pricegroup->get_all_sorted;
941 # model used to filter/display the parts in the multi-items dialog
942 sub init_multi_items_models {
943 SL::Controller::Helper::GetModels->new(
946 with_objects => [ qw(unit_obj partsgroup classification) ],
947 disable_plugin => 'paginated',
948 source => $::form->{multi_items},
954 partnumber => t8('Partnumber'),
955 description => t8('Description')}
959 # simple checks to run on $::form before saving
961 sub form_check_part_description_exists {
964 return 1 if $::form->{part}{description};
966 $self->js->flash('error', t8('Part Description missing!'))
967 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
968 ->focus('#part_description');
972 sub form_check_assortment_items_exist {
975 return 1 unless $::form->{part}{part_type} eq 'assortment';
976 # skip item check for existing assortments that have been used
977 return 1 if ($self->part->id and !$self->part->orphaned);
979 # new or orphaned parts must have items in $::form->{assortment_items}
980 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
981 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
982 ->focus('#add_assortment_item_name')
983 ->flash('error', t8('The assortment doesn\'t have any items.'));
989 sub form_check_assortment_items_unique {
992 return 1 unless $::form->{part}{part_type} eq 'assortment';
994 my %duplicate_elements;
996 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
997 $duplicate_elements{$_}++ if $count{$_}++;
1000 if ( keys %duplicate_elements ) {
1001 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1002 ->flash('error', t8('There are duplicate assortment items'));
1008 sub form_check_assembly_items_exist {
1011 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1013 # skip item check for existing assembly that have been used
1014 return 1 if ($self->part->id and !$self->part->orphaned);
1016 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1017 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1018 ->focus('#add_assembly_item_name')
1019 ->flash('error', t8('The assembly doesn\'t have any items.'));
1025 sub form_check_partnumber_is_unique {
1028 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1029 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1031 $self->js->flash('error', t8('The partnumber already exists!'))
1032 ->focus('#part_description');
1039 # general checking functions
1040 sub check_next_transnumber_is_free {
1043 my ($next_transnumber, $count);
1044 $self->part->db->with_transaction(sub {
1045 $next_transnumber = $self->part->get_next_trans_number;
1046 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1049 $count ? return 0 : return 1;
1053 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1059 $self->form_check_part_description_exists || return 0;
1060 $self->form_check_assortment_items_exist || return 0;
1061 $self->form_check_assortment_items_unique || return 0;
1062 $self->form_check_assembly_items_exist || return 0;
1063 $self->form_check_partnumber_is_unique || return 0;
1068 sub check_has_valid_part_type {
1069 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1072 sub render_assortment_items_to_html {
1073 my ($self, $assortment_items, $number_of_items) = @_;
1075 my $position = $number_of_items + 1;
1077 foreach my $ai (@$assortment_items) {
1078 $html .= $self->p->render('part/_assortment_row',
1079 PART => $self->part,
1080 orphaned => $self->orphaned,
1082 listrow => $position % 2 ? 1 : 0,
1083 position => $position, # for legacy assemblies
1090 sub render_assembly_items_to_html {
1091 my ($self, $assembly_items, $number_of_items) = @_;
1093 my $position = $number_of_items + 1;
1095 foreach my $ai (@{$assembly_items}) {
1096 $html .= $self->p->render('part/_assembly_row',
1097 PART => $self->part,
1098 orphaned => $self->orphaned,
1100 listrow => $position % 2 ? 1 : 0,
1101 position => $position, # for legacy assemblies
1108 sub parse_add_items_to_objects {
1109 my ($self, %params) = @_;
1110 my $part_type = $params{part_type};
1111 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1112 my $position = $params{position} || 1;
1114 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1117 foreach my $item ( @add_items ) {
1118 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1120 if ( $part_type eq 'assortment' ) {
1121 $ai = SL::DB::AssortmentItem->new(part => $part,
1122 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1123 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1124 position => $position,
1125 ) or die "Can't create AssortmentItem from item";
1126 } elsif ( $part_type eq 'assembly' ) {
1127 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1128 # id => $self->assembly->id, # will be set on save
1129 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1130 bom => 0, # default when adding: no bom
1131 position => $position,
1134 die "part_type must be assortment or assembly";
1136 push(@item_objects, $ai);
1140 return \@item_objects;
1143 sub _setup_form_action_bar {
1146 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1148 for my $bar ($::request->layout->get('actionbar')) {
1153 call => [ 'kivi.Part.save' ],
1154 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1155 accesskey => 'enter',
1159 call => [ 'kivi.Part.use_as_new' ],
1160 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1161 : !$may_edit ? t8('You do not have the permissions to access this function.')
1164 ], # end of combobox "Save"
1168 call => [ 'kivi.Part.delete' ],
1169 confirm => t8('Do you really want to delete this object?'),
1170 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1171 : !$may_edit ? t8('You do not have the permissions to access this function.')
1172 : !$self->part->orphaned ? t8('This object has already been used.')
1180 call => [ 'kivi.Part.open_history_popup' ],
1181 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1182 : !$may_edit ? t8('You do not have the permissions to access this function.')
1197 SL::Controller::Part - Part CRUD controller
1201 Controller for adding/editing/saving/deleting parts.
1203 All the relations are loaded at once and saving the part, adding a history
1204 entry and saving CVars happens inside one transaction. When saving the old
1205 relations are deleted and written as new to the database.
1207 Relations for parts:
1215 =item assembly items
1217 =item assortment items
1225 There are 4 different part types:
1231 The "default" part type.
1233 inventory_accno_id is set.
1237 Services can't be stocked.
1239 inventory_accno_id isn't set.
1243 Assemblies consist of other parts, services, assemblies or assortments. They
1244 aren't meant to be bought, only sold. To add assemblies to stock you typically
1245 have to make them, which reduces the stock by its respective components. Once
1246 an assembly item has been created there is currently no way to "disassemble" it
1247 again. An assembly item can appear several times in one assembly. An assmbly is
1248 sold as one item with a defined sellprice and lastcost. If the component prices
1249 change the assortment price remains the same. The assembly items may be printed
1250 in a record if the item's "bom" is set.
1254 Similar to assembly, but each assortment item may only appear once per
1255 assortment. When selling an assortment the assortment items are added to the
1256 record together with the assortment, which is added with sellprice 0.
1258 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1259 determined by the sum of the current assortment item prices when the assortment
1260 is added to a record. This also means that price rules and customer discounts
1261 will be applied to the assortment items.
1263 Once the assortment items have been added they may be modified or deleted, just
1264 as if they had been added manually, the individual assortment items aren't
1265 linked to the assortment or the other assortment items in any way.
1273 =item C<action_add_part>
1275 =item C<action_add_service>
1277 =item C<action_add_assembly>
1279 =item C<action_add_assortment>
1281 =item C<action_add PART_TYPE>
1283 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1284 parameter part_type as an action. Example:
1286 controller.pl?action=Part/add&part_type=service
1288 =item C<action_save>
1290 Saves the current part and then reloads the edit page for the part.
1292 =item C<action_use_as_new>
1294 Takes the information from the current part, plus any modifications made on the
1295 page, and creates a new edit page that is ready to be saved. The partnumber is
1296 set empty, so a new partnumber from the number range will be used if the user
1297 doesn't enter one manually.
1299 Unsaved changes to the original part aren't updated.
1301 The part type cannot be changed in this way.
1303 =item C<action_delete>
1305 Deletes the current part and then redirects to the main page, there is no
1308 The delete button only appears if the part is 'orphaned', according to
1309 SL::DB::Part orphaned.
1311 The part can't be deleted if it appears in invoices, orders, delivery orders,
1312 the inventory, or is part of an assembly or assortment.
1314 If the part is deleted its relations prices, makdemodel, assembly,
1315 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1317 Before this controller items that appeared in inventory didn't count as
1318 orphaned and could be deleted and the inventory entries were also deleted, this
1319 "feature" hasn't been implemented.
1321 =item C<action_edit part.id>
1323 Load and display a part for editing.
1325 controller.pl?action=Part/edit&part.id=12345
1327 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1331 =head1 BUTTON ACTIONS
1337 Opens a popup displaying all the history entries. Once a new history controller
1338 is written the button could link there instead, with the part already selected.
1346 =item C<action_update_item_totals>
1348 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1349 amount of an item changes. The sum of all sellprices and lastcosts is
1350 calculated and the totals updated. Uses C<recalc_item_totals>.
1352 =item C<action_add_assortment_item>
1354 Adds a new assortment item from a part picker seleciton to the assortment item list
1356 If the item already exists in the assortment the item isn't added and a Flash
1359 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1360 after adding each new item, add the new object to the item objects that were
1361 already parsed, calculate totals via a dummy part then update the row and the
1364 =item C<action_add_assembly_item>
1366 Adds a new assembly item from a part picker seleciton to the assembly item list
1368 If the item already exists in the assembly a flash info is generated, but the
1371 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1372 after adding each new item, add the new object to the item objects that were
1373 already parsed, calculate totals via a dummy part then update the row and the
1376 =item C<action_add_multi_assortment_items>
1378 Parses the items to be added from the form generated by the multi input and
1379 appends the html of the tr-rows to the assortment item table. Afterwards all
1380 assortment items are renumbered and the sums recalculated via
1381 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1383 =item C<action_add_multi_assembly_items>
1385 Parses the items to be added from the form generated by the multi input and
1386 appends the html of the tr-rows to the assembly item table. Afterwards all
1387 assembly items are renumbered and the sums recalculated via
1388 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1390 =item C<action_show_multi_items_dialog>
1392 =item C<action_multi_items_update_result>
1394 =item C<action_add_makemodel_row>
1396 Add a new makemodel row with the vendor that was selected via the vendor
1399 Checks the already existing makemodels and warns if a row with that vendor
1400 already exists. Currently it is possible to have duplicate vendor rows.
1402 =item C<action_reorder_items>
1404 Sorts the item table for assembly or assortment items.
1406 =item C<action_warehouse_changed>
1410 =head1 ACTIONS part picker
1414 =item C<action_ajax_autocomplete>
1416 =item C<action_test_page>
1418 =item C<action_part_picker_search>
1420 =item C<action_part_picker_result>
1422 =item C<action_show>
1432 Calls some simple checks that test the submitted $::form for obvious errors.
1433 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1435 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1436 some cases extra actions are taken, e.g. if the part description is missing the
1437 basic data tab is selected and the description input field is focussed.
1443 =item C<form_check_part_description_exists>
1445 =item C<form_check_assortment_items_exist>
1447 =item C<form_check_assortment_items_unique>
1449 =item C<form_check_assembly_items_exist>
1451 =item C<form_check_partnumber_is_unique>
1455 =head1 HELPER FUNCTIONS
1461 When submitting the form for saving, parses the transmitted form. Expects the
1465 $::form->{makemodels}
1466 $::form->{translations}
1468 $::form->{assemblies}
1469 $::form->{assortments}
1471 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1473 =item C<recalc_item_totals %params>
1475 Helper function for calculating the total lastcost and sellprice for assemblies
1476 or assortments according to their items, which are parsed from the current
1479 Is called whenever the qty of an item is changed or items are deleted.
1483 * part_type : 'assortment' or 'assembly' (mandatory)
1485 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1487 Depending on the price_type the lastcost sum or sellprice sum is returned.
1489 Doesn't work for recursive items.
1493 =head1 GET SET INITS
1495 There are get_set_inits for
1503 which parse $::form and automatically create an array of objects.
1505 These inits are used during saving and each time a new element is added.
1509 =item C<init_makemodels>
1511 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1512 $self->part->makemodels, ready to be saved.
1514 Used for saving parts and adding new makemodel rows.
1516 =item C<parse_add_items_to_objects PART_TYPE>
1518 Parses the resulting form from either the part-picker submit or the multi-item
1519 submit, and creates an arrayref of assortment_item or assembly objects, that
1520 can be rendered via C<render_assortment_items_to_html> or
1521 C<render_assembly_items_to_html>.
1523 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1524 Optional param: position (used for numbering and listrow class)
1526 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1528 Takes an array_ref of assortment_items, and generates tables rows ready for
1529 adding to the assortment table. Is used when a part is loaded, or whenever new
1530 assortment items are added.
1532 =item C<parse_form_makemodels>
1534 Makemodels can't just be overwritten, because of the field "lastupdate", that
1535 remembers when the lastcost for that vendor changed the last time.
1537 So the original values are cloned and remembered, so we can compare if lastcost
1538 was changed in $::form, and keep or update lastupdate.
1540 lastcost isn't updated until the first time it was saved with a value, until
1543 Also a boolean "makemodel" needs to be written in parts, depending on whether
1544 makemodel entries exist or not.
1546 We still need init_makemodels for when we open the part for editing.
1556 It should be possible to jump to the edit page in a specific tab
1560 Support callbacks, e.g. creating a new part from within an order, and jumping
1561 back to the order again afterwards.
1565 Support units when adding assembly items or assortment items. Currently the
1566 default unit of the item is always used.
1570 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1571 consists of other assemblies.
1577 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>