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 parts_classification_filter
30 all_languages all_units all_price_factors) ],
31 'scalar' => [ qw(warehouse bin) ],
35 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
36 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
38 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
40 # actions for editing parts
43 my ($self, %params) = @_;
45 $self->part( SL::DB::Part->new_part );
49 sub action_add_service {
50 my ($self, %params) = @_;
52 $self->part( SL::DB::Part->new_service );
56 sub action_add_assembly {
57 my ($self, %params) = @_;
59 $self->part( SL::DB::Part->new_assembly );
63 sub action_add_assortment {
64 my ($self, %params) = @_;
66 $self->part( SL::DB::Part->new_assortment );
70 sub action_add_from_record {
73 check_has_valid_part_type($::form->{part}{part_type});
75 die 'parts_classification_type must be "sales" or "purchases"'
76 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
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 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
197 sub action_use_as_new {
198 my ($self, %params) = @_;
200 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
201 $::form->{oldpartnumber} = $oldpart->partnumber;
203 $self->part($oldpart->clone_and_reset_deep);
205 $self->part->partnumber(undef);
211 my ($self, %params) = @_;
217 my ($self, %params) = @_;
219 $self->_set_javascript;
220 $self->_setup_form_action_bar;
222 my (%assortment_vars, %assembly_vars);
223 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
224 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
226 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
228 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
229 if (scalar @{ $params{CUSTOM_VARIABLES} });
231 my %title_hash = ( part => t8('Edit Part'),
232 assembly => t8('Edit Assembly'),
233 service => t8('Edit Service'),
234 assortment => t8('Edit Assortment'),
237 $self->part->prices([]) unless $self->part->prices;
238 $self->part->translations([]) unless $self->part->translations;
242 title => $title_hash{$self->part->part_type},
245 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
246 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
247 oldpartnumber => $::form->{oldpartnumber},
248 old_id => $::form->{old_id},
256 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
257 $_[0]->render('part/history', { layout => 0 },
258 history_entries => $history_entries);
261 sub action_update_item_totals {
264 my $part_type = $::form->{part_type};
265 die unless $part_type =~ /^(assortment|assembly)$/;
267 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
268 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
270 my $sum_diff = $sellprice_sum-$lastcost_sum;
273 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
274 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
275 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
276 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
277 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
278 ->no_flash_clear->render();
281 sub action_add_multi_assortment_items {
284 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
285 my $html = $self->render_assortment_items_to_html($item_objects);
287 $self->js->run('kivi.Part.close_picker_dialogs')
288 ->append('#assortment_rows', $html)
289 ->run('kivi.Part.renumber_positions')
290 ->run('kivi.Part.assortment_recalc')
294 sub action_add_multi_assembly_items {
297 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
299 foreach my $item (@{$item_objects}) {
300 my $errstr = validate_assembly($item->part,$self->part);
301 $self->js->flash('error',$errstr) if $errstr;
302 push (@checked_objects,$item) unless $errstr;
305 my $html = $self->render_assembly_items_to_html(\@checked_objects);
307 $self->js->run('kivi.Part.close_picker_dialogs')
308 ->append('#assembly_rows', $html)
309 ->run('kivi.Part.renumber_positions')
310 ->run('kivi.Part.assembly_recalc')
314 sub action_add_assortment_item {
315 my ($self, %params) = @_;
317 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
319 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
321 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
322 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
323 return $self->js->flash('error', t8("This part has already been added."))->render;
326 my $number_of_items = scalar @{$self->assortment_items};
327 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
328 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
330 push(@{$self->assortment_items}, @{$item_objects});
331 my $part = SL::DB::Part->new(part_type => 'assortment');
332 $part->assortment_items(@{$self->assortment_items});
333 my $items_sellprice_sum = $part->items_sellprice_sum;
334 my $items_lastcost_sum = $part->items_lastcost_sum;
335 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
338 ->append('#assortment_rows' , $html) # append in tbody
339 ->val('.add_assortment_item_input' , '')
340 ->run('kivi.Part.focus_last_assortment_input')
341 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
342 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
343 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
344 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
345 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
349 sub action_add_assembly_item {
352 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
354 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
356 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
358 my $duplicate_warning = 0; # duplicates are allowed, just warn
359 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
360 $duplicate_warning++;
363 my $number_of_items = scalar @{$self->assembly_items};
364 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
366 foreach my $item (@{$item_objects}) {
367 my $errstr = validate_assembly($item->part,$self->part);
368 return $self->js->flash('error',$errstr)->render if $errstr;
373 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
375 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
377 push(@{$self->assembly_items}, @{$item_objects});
378 my $part = SL::DB::Part->new(part_type => 'assembly');
379 $part->assemblies(@{$self->assembly_items});
380 my $items_sellprice_sum = $part->items_sellprice_sum;
381 my $items_lastcost_sum = $part->items_lastcost_sum;
382 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
385 ->append('#assembly_rows', $html) # append in tbody
386 ->val('.add_assembly_item_input' , '')
387 ->run('kivi.Part.focus_last_assembly_input')
388 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
389 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
390 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
391 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
392 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
396 sub action_show_multi_items_dialog {
397 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
398 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
402 sub action_multi_items_update_result {
405 $::form->{multi_items}->{filter}->{obsolete} = 0;
407 my $count = $_[0]->multi_items_models->count;
410 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
411 $_[0]->render($text, { layout => 0 });
412 } elsif ($count > $max_count) {
413 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
414 $_[0]->render($text, { layout => 0 });
416 my $multi_items = $_[0]->multi_items_models->get;
417 $_[0]->render('part/_multi_items_result', { layout => 0 },
418 multi_items => $multi_items);
422 sub action_add_makemodel_row {
425 my $vendor_id = $::form->{add_makemodel};
427 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
428 return $self->js->error(t8("No vendor selected or found!"))->render;
430 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
431 $self->js->flash('info', t8("This vendor has already been added."));
434 my $position = scalar @{$self->makemodels} + 1;
436 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
440 sortorder => $position,
441 ) or die "Can't create MakeModel object";
443 my $row_as_html = $self->p->render('part/_makemodel_row',
445 listrow => $position % 2 ? 0 : 1,
448 # after selection focus on the model field in the row that was just added
450 ->append('#makemodel_rows', $row_as_html) # append in tbody
451 ->val('.add_makemodel_input', '')
452 ->run('kivi.Part.focus_last_makemodel_input')
456 sub action_reorder_items {
459 my $part_type = $::form->{part_type};
462 partnumber => sub { $_[0]->part->partnumber },
463 description => sub { $_[0]->part->description },
464 qty => sub { $_[0]->qty },
465 sellprice => sub { $_[0]->part->sellprice },
466 lastcost => sub { $_[0]->part->lastcost },
467 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
470 my $method = $sort_keys{$::form->{order_by}};
473 if ($part_type eq 'assortment') {
474 @items = @{ $self->assortment_items };
476 @items = @{ $self->assembly_items };
479 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
480 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
481 if ($::form->{sort_dir}) {
482 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
484 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
487 if ($::form->{sort_dir}) {
488 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
490 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
494 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
497 sub action_warehouse_changed {
500 if ($::form->{warehouse_id} ) {
501 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
502 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
504 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
505 $self->bin($self->warehouse->bins->[0]);
507 ->html('#bin', $self->build_bin_select)
508 ->focus('#part_bin_id');
509 return $self->js->render;
513 # no warehouse was selected, empty the bin field and reset the id
515 ->val('#part_bin_id', undef)
518 return $self->js->render;
521 sub action_ajax_autocomplete {
522 my ($self, %params) = @_;
524 # if someone types something, and hits enter, assume he entered the full name.
525 # if something matches, treat that as sole match
526 # since we need a second get models instance with different filters for that,
527 # we only modify the original filter temporarily in place
528 if ($::form->{prefer_exact}) {
529 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
531 my $exact_models = SL::Controller::Helper::GetModels->new(
534 paginated => { per_page => 2 },
535 with_objects => [ qw(unit_obj classification) ],
538 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
539 $self->parts($exact_matches);
545 value => $_->displayable_name,
546 label => $_->displayable_name,
548 partnumber => $_->partnumber,
549 description => $_->description,
550 part_type => $_->part_type,
552 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
554 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
556 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
559 sub action_test_page {
560 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
563 sub action_part_picker_search {
564 $_[0]->render('part/part_picker_search', { layout => 0 });
567 sub action_part_picker_result {
568 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
574 if ($::request->type eq 'json') {
579 $part_hash = $self->part->as_tree;
580 $part_hash->{cvars} = $self->part->cvar_as_hashref;
583 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
588 sub validate_add_items {
589 scalar @{$::form->{add_items}};
592 sub prepare_assortment_render_vars {
595 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
596 items_lastcost_sum => $self->part->items_lastcost_sum,
597 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
599 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
604 sub prepare_assembly_render_vars {
607 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
608 items_lastcost_sum => $self->part->items_lastcost_sum,
609 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
611 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
619 check_has_valid_part_type($self->part->part_type);
621 $self->_set_javascript;
622 $self->_setup_form_action_bar;
624 my %title_hash = ( part => t8('Add Part'),
625 assembly => t8('Add Assembly'),
626 service => t8('Add Service'),
627 assortment => t8('Add Assortment'),
632 title => $title_hash{$self->part->part_type},
637 sub _set_javascript {
639 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
640 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
643 sub recalc_item_totals {
644 my ($self, %params) = @_;
646 if ( $params{part_type} eq 'assortment' ) {
647 return 0 unless scalar @{$self->assortment_items};
648 } elsif ( $params{part_type} eq 'assembly' ) {
649 return 0 unless scalar @{$self->assembly_items};
651 carp "can only calculate sum for assortments and assemblies";
654 my $part = SL::DB::Part->new(part_type => $params{part_type});
655 if ( $part->is_assortment ) {
656 $part->assortment_items( @{$self->assortment_items} );
657 if ( $params{price_type} eq 'lastcost' ) {
658 return $part->items_lastcost_sum;
660 if ( $params{pricegroup_id} ) {
661 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
663 return $part->items_sellprice_sum;
666 } elsif ( $part->is_assembly ) {
667 $part->assemblies( @{$self->assembly_items} );
668 if ( $params{price_type} eq 'lastcost' ) {
669 return $part->items_lastcost_sum;
671 return $part->items_sellprice_sum;
676 sub check_part_not_modified {
679 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
686 my $is_new = !$self->part->id;
688 my $params = delete($::form->{part}) || { };
690 delete $params->{id};
691 $self->part->assign_attributes(%{ $params});
692 $self->part->bin_id(undef) unless $self->part->warehouse_id;
694 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
695 # will be the case for used assortments when saving, or when a used assortment
697 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
698 $self->part->assortment_items([]);
699 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
702 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
703 $self->part->assemblies([]); # completely rewrite assortments each time
704 $self->part->add_assemblies( @{ $self->assembly_items } );
707 $self->part->translations([]);
708 $self->parse_form_translations;
710 $self->part->prices([]);
711 $self->parse_form_prices;
713 $self->parse_form_makemodels;
716 sub parse_form_prices {
718 # only save prices > 0
719 my $prices = delete($::form->{prices}) || [];
720 foreach my $price ( @{$prices} ) {
721 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
722 next unless $sellprice > 0; # skip negative prices as well
723 my $p = SL::DB::Price->new(parts_id => $self->part->id,
724 pricegroup_id => $price->{pricegroup_id},
727 $self->part->add_prices($p);
731 sub parse_form_translations {
733 # don't add empty translations
734 my $translations = delete($::form->{translations}) || [];
735 foreach my $translation ( @{$translations} ) {
736 next unless $translation->{translation};
737 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
738 $self->part->add_translations( $translation );
742 sub parse_form_makemodels {
746 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
747 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
750 $self->part->makemodels([]);
753 my $makemodels = delete($::form->{makemodels}) || [];
754 foreach my $makemodel ( @{$makemodels} ) {
755 next unless $makemodel->{make};
757 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
759 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
760 id => $makemodel->{id},
761 make => $makemodel->{make},
762 model => $makemodel->{model} || '',
763 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
764 sortorder => $position,
766 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
767 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
768 # don't change lastupdate
769 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
770 # new makemodel, no lastcost entered, leave lastupdate empty
771 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
772 # lastcost hasn't changed, use original lastupdate
773 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
775 $mm->lastupdate(DateTime->now);
777 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
778 $self->part->add_makemodels($mm);
782 sub build_bin_select {
783 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
784 title_key => 'description',
785 default => $_[0]->bin->id,
789 # get_set_inits for partpicker
792 if ($::form->{no_paginate}) {
793 $_[0]->models->disable_plugin('paginated');
799 # get_set_inits for part controller
803 # used by edit, save, delete and add
805 if ( $::form->{part}{id} ) {
806 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
808 die "part_type missing" unless $::form->{part}{part_type};
809 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
815 return $self->part->orphaned;
821 SL::Controller::Helper::GetModels->new(
828 partnumber => t8('Partnumber'),
829 description => t8('Description'),
831 with_objects => [ qw(unit_obj classification) ],
840 sub init_assortment_items {
841 # this init is used while saving and whenever assortments change dynamically
845 my $assortment_items = delete($::form->{assortment_items}) || [];
846 foreach my $assortment_item ( @{$assortment_items} ) {
847 next unless $assortment_item->{parts_id};
849 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
850 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
851 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
852 charge => $assortment_item->{charge},
853 unit => $assortment_item->{unit} || $part->unit,
854 position => $position,
862 sub init_makemodels {
866 my @makemodel_array = ();
867 my $makemodels = delete($::form->{makemodels}) || [];
869 foreach my $makemodel ( @{$makemodels} ) {
870 next unless $makemodel->{make};
872 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
873 id => $makemodel->{id},
874 make => $makemodel->{make},
875 model => $makemodel->{model} || '',
876 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
877 sortorder => $position,
878 ) or die "Can't create mm";
879 # $mm->id($makemodel->{id}) if $makemodel->{id};
880 push(@makemodel_array, $mm);
882 return \@makemodel_array;
885 sub init_assembly_items {
889 my $assembly_items = delete($::form->{assembly_items}) || [];
890 foreach my $assembly_item ( @{$assembly_items} ) {
891 next unless $assembly_item->{parts_id};
893 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
894 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
895 bom => $assembly_item->{bom},
896 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
897 position => $position,
904 sub init_all_warehouses {
906 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
909 sub init_all_languages {
910 SL::DB::Manager::Language->get_all_sorted;
913 sub init_all_partsgroups {
915 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
918 sub init_all_buchungsgruppen {
920 if ( $self->part->orphaned ) {
921 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
923 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
929 if ( $self->part->orphaned ) {
930 return SL::DB::Manager::Unit->get_all_sorted;
932 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
936 sub init_all_payment_terms {
938 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
941 sub init_all_price_factors {
942 SL::DB::Manager::PriceFactor->get_all_sorted;
945 sub init_all_pricegroups {
946 SL::DB::Manager::Pricegroup->get_all_sorted;
949 # model used to filter/display the parts in the multi-items dialog
950 sub init_multi_items_models {
951 SL::Controller::Helper::GetModels->new(
954 with_objects => [ qw(unit_obj partsgroup classification) ],
955 disable_plugin => 'paginated',
956 source => $::form->{multi_items},
962 partnumber => t8('Partnumber'),
963 description => t8('Description')}
967 sub init_parts_classification_filter {
968 return [] unless $::form->{parts_classification_type};
970 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
971 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
973 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
976 # simple checks to run on $::form before saving
978 sub form_check_part_description_exists {
981 return 1 if $::form->{part}{description};
983 $self->js->flash('error', t8('Part Description missing!'))
984 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
985 ->focus('#part_description');
989 sub form_check_assortment_items_exist {
992 return 1 unless $::form->{part}{part_type} eq 'assortment';
993 # skip item check for existing assortments that have been used
994 return 1 if ($self->part->id and !$self->part->orphaned);
996 # new or orphaned parts must have items in $::form->{assortment_items}
997 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
998 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
999 ->focus('#add_assortment_item_name')
1000 ->flash('error', t8('The assortment doesn\'t have any items.'));
1006 sub form_check_assortment_items_unique {
1009 return 1 unless $::form->{part}{part_type} eq 'assortment';
1011 my %duplicate_elements;
1013 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1014 $duplicate_elements{$_}++ if $count{$_}++;
1017 if ( keys %duplicate_elements ) {
1018 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1019 ->flash('error', t8('There are duplicate assortment items'));
1025 sub form_check_assembly_items_exist {
1028 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1030 # skip item check for existing assembly that have been used
1031 return 1 if ($self->part->id and !$self->part->orphaned);
1033 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1034 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1035 ->focus('#add_assembly_item_name')
1036 ->flash('error', t8('The assembly doesn\'t have any items.'));
1042 sub form_check_partnumber_is_unique {
1045 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1046 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1048 $self->js->flash('error', t8('The partnumber already exists!'))
1049 ->focus('#part_description');
1056 # general checking functions
1059 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1065 $self->form_check_part_description_exists || return 0;
1066 $self->form_check_assortment_items_exist || return 0;
1067 $self->form_check_assortment_items_unique || return 0;
1068 $self->form_check_assembly_items_exist || return 0;
1069 $self->form_check_partnumber_is_unique || return 0;
1074 sub check_has_valid_part_type {
1075 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1078 sub render_assortment_items_to_html {
1079 my ($self, $assortment_items, $number_of_items) = @_;
1081 my $position = $number_of_items + 1;
1083 foreach my $ai (@$assortment_items) {
1084 $html .= $self->p->render('part/_assortment_row',
1085 PART => $self->part,
1086 orphaned => $self->orphaned,
1088 listrow => $position % 2 ? 1 : 0,
1089 position => $position, # for legacy assemblies
1096 sub render_assembly_items_to_html {
1097 my ($self, $assembly_items, $number_of_items) = @_;
1099 my $position = $number_of_items + 1;
1101 foreach my $ai (@{$assembly_items}) {
1102 $html .= $self->p->render('part/_assembly_row',
1103 PART => $self->part,
1104 orphaned => $self->orphaned,
1106 listrow => $position % 2 ? 1 : 0,
1107 position => $position, # for legacy assemblies
1114 sub parse_add_items_to_objects {
1115 my ($self, %params) = @_;
1116 my $part_type = $params{part_type};
1117 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1118 my $position = $params{position} || 1;
1120 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1123 foreach my $item ( @add_items ) {
1124 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1126 if ( $part_type eq 'assortment' ) {
1127 $ai = SL::DB::AssortmentItem->new(part => $part,
1128 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1129 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1130 position => $position,
1131 ) or die "Can't create AssortmentItem from item";
1132 } elsif ( $part_type eq 'assembly' ) {
1133 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1134 # id => $self->assembly->id, # will be set on save
1135 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1136 bom => 0, # default when adding: no bom
1137 position => $position,
1140 die "part_type must be assortment or assembly";
1142 push(@item_objects, $ai);
1146 return \@item_objects;
1149 sub _setup_form_action_bar {
1152 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1154 for my $bar ($::request->layout->get('actionbar')) {
1159 call => [ 'kivi.Part.save' ],
1160 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1161 accesskey => 'enter',
1165 call => [ 'kivi.Part.use_as_new' ],
1166 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1167 : !$may_edit ? t8('You do not have the permissions to access this function.')
1170 ], # end of combobox "Save"
1174 call => [ 'kivi.Part.delete' ],
1175 confirm => t8('Do you really want to delete this object?'),
1176 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1177 : !$may_edit ? t8('You do not have the permissions to access this function.')
1178 : !$self->part->orphaned ? t8('This object has already been used.')
1186 call => [ 'kivi.Part.open_history_popup' ],
1187 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1188 : !$may_edit ? t8('You do not have the permissions to access this function.')
1203 SL::Controller::Part - Part CRUD controller
1207 Controller for adding/editing/saving/deleting parts.
1209 All the relations are loaded at once and saving the part, adding a history
1210 entry and saving CVars happens inside one transaction. When saving the old
1211 relations are deleted and written as new to the database.
1213 Relations for parts:
1221 =item assembly items
1223 =item assortment items
1231 There are 4 different part types:
1237 The "default" part type.
1239 inventory_accno_id is set.
1243 Services can't be stocked.
1245 inventory_accno_id isn't set.
1249 Assemblies consist of other parts, services, assemblies or assortments. They
1250 aren't meant to be bought, only sold. To add assemblies to stock you typically
1251 have to make them, which reduces the stock by its respective components. Once
1252 an assembly item has been created there is currently no way to "disassemble" it
1253 again. An assembly item can appear several times in one assembly. An assmbly is
1254 sold as one item with a defined sellprice and lastcost. If the component prices
1255 change the assortment price remains the same. The assembly items may be printed
1256 in a record if the item's "bom" is set.
1260 Similar to assembly, but each assortment item may only appear once per
1261 assortment. When selling an assortment the assortment items are added to the
1262 record together with the assortment, which is added with sellprice 0.
1264 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1265 determined by the sum of the current assortment item prices when the assortment
1266 is added to a record. This also means that price rules and customer discounts
1267 will be applied to the assortment items.
1269 Once the assortment items have been added they may be modified or deleted, just
1270 as if they had been added manually, the individual assortment items aren't
1271 linked to the assortment or the other assortment items in any way.
1279 =item C<action_add_part>
1281 =item C<action_add_service>
1283 =item C<action_add_assembly>
1285 =item C<action_add_assortment>
1287 =item C<action_add PART_TYPE>
1289 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1290 parameter part_type as an action. Example:
1292 controller.pl?action=Part/add&part_type=service
1294 =item C<action_add_from_record>
1296 When adding new items to records they can be created on the fly if the entered
1297 partnumber or description doesn't exist yet. After being asked what part type
1298 the new item should have the user is redirected to the correct edit page.
1300 Depending on whether the item was added from a sales or a purchase record, only
1301 the relevant part classifications should be selectable for new item, so this
1302 parameter is passed on via a hidden parts_classification_type in the new_item
1305 =item C<action_save>
1307 Saves the current part and then reloads the edit page for the part.
1309 =item C<action_use_as_new>
1311 Takes the information from the current part, plus any modifications made on the
1312 page, and creates a new edit page that is ready to be saved. The partnumber is
1313 set empty, so a new partnumber from the number range will be used if the user
1314 doesn't enter one manually.
1316 Unsaved changes to the original part aren't updated.
1318 The part type cannot be changed in this way.
1320 =item C<action_delete>
1322 Deletes the current part and then redirects to the main page, there is no
1325 The delete button only appears if the part is 'orphaned', according to
1326 SL::DB::Part orphaned.
1328 The part can't be deleted if it appears in invoices, orders, delivery orders,
1329 the inventory, or is part of an assembly or assortment.
1331 If the part is deleted its relations prices, makdemodel, assembly,
1332 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1334 Before this controller items that appeared in inventory didn't count as
1335 orphaned and could be deleted and the inventory entries were also deleted, this
1336 "feature" hasn't been implemented.
1338 =item C<action_edit part.id>
1340 Load and display a part for editing.
1342 controller.pl?action=Part/edit&part.id=12345
1344 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1348 =head1 BUTTON ACTIONS
1354 Opens a popup displaying all the history entries. Once a new history controller
1355 is written the button could link there instead, with the part already selected.
1363 =item C<action_update_item_totals>
1365 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1366 amount of an item changes. The sum of all sellprices and lastcosts is
1367 calculated and the totals updated. Uses C<recalc_item_totals>.
1369 =item C<action_add_assortment_item>
1371 Adds a new assortment item from a part picker seleciton to the assortment item list
1373 If the item already exists in the assortment the item isn't added and a Flash
1376 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1377 after adding each new item, add the new object to the item objects that were
1378 already parsed, calculate totals via a dummy part then update the row and the
1381 =item C<action_add_assembly_item>
1383 Adds a new assembly item from a part picker seleciton to the assembly item list
1385 If the item already exists in the assembly a flash info is generated, but the
1388 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1389 after adding each new item, add the new object to the item objects that were
1390 already parsed, calculate totals via a dummy part then update the row and the
1393 =item C<action_add_multi_assortment_items>
1395 Parses the items to be added from the form generated by the multi input and
1396 appends the html of the tr-rows to the assortment item table. Afterwards all
1397 assortment items are renumbered and the sums recalculated via
1398 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1400 =item C<action_add_multi_assembly_items>
1402 Parses the items to be added from the form generated by the multi input and
1403 appends the html of the tr-rows to the assembly item table. Afterwards all
1404 assembly items are renumbered and the sums recalculated via
1405 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1407 =item C<action_show_multi_items_dialog>
1409 =item C<action_multi_items_update_result>
1411 =item C<action_add_makemodel_row>
1413 Add a new makemodel row with the vendor that was selected via the vendor
1416 Checks the already existing makemodels and warns if a row with that vendor
1417 already exists. Currently it is possible to have duplicate vendor rows.
1419 =item C<action_reorder_items>
1421 Sorts the item table for assembly or assortment items.
1423 =item C<action_warehouse_changed>
1427 =head1 ACTIONS part picker
1431 =item C<action_ajax_autocomplete>
1433 =item C<action_test_page>
1435 =item C<action_part_picker_search>
1437 =item C<action_part_picker_result>
1439 =item C<action_show>
1449 Calls some simple checks that test the submitted $::form for obvious errors.
1450 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1452 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1453 some cases extra actions are taken, e.g. if the part description is missing the
1454 basic data tab is selected and the description input field is focussed.
1460 =item C<form_check_part_description_exists>
1462 =item C<form_check_assortment_items_exist>
1464 =item C<form_check_assortment_items_unique>
1466 =item C<form_check_assembly_items_exist>
1468 =item C<form_check_partnumber_is_unique>
1472 =head1 HELPER FUNCTIONS
1478 When submitting the form for saving, parses the transmitted form. Expects the
1482 $::form->{makemodels}
1483 $::form->{translations}
1485 $::form->{assemblies}
1486 $::form->{assortments}
1488 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1490 =item C<recalc_item_totals %params>
1492 Helper function for calculating the total lastcost and sellprice for assemblies
1493 or assortments according to their items, which are parsed from the current
1496 Is called whenever the qty of an item is changed or items are deleted.
1500 * part_type : 'assortment' or 'assembly' (mandatory)
1502 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1504 Depending on the price_type the lastcost sum or sellprice sum is returned.
1506 Doesn't work for recursive items.
1510 =head1 GET SET INITS
1512 There are get_set_inits for
1520 which parse $::form and automatically create an array of objects.
1522 These inits are used during saving and each time a new element is added.
1526 =item C<init_makemodels>
1528 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1529 $self->part->makemodels, ready to be saved.
1531 Used for saving parts and adding new makemodel rows.
1533 =item C<parse_add_items_to_objects PART_TYPE>
1535 Parses the resulting form from either the part-picker submit or the multi-item
1536 submit, and creates an arrayref of assortment_item or assembly objects, that
1537 can be rendered via C<render_assortment_items_to_html> or
1538 C<render_assembly_items_to_html>.
1540 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1541 Optional param: position (used for numbering and listrow class)
1543 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1545 Takes an array_ref of assortment_items, and generates tables rows ready for
1546 adding to the assortment table. Is used when a part is loaded, or whenever new
1547 assortment items are added.
1549 =item C<parse_form_makemodels>
1551 Makemodels can't just be overwritten, because of the field "lastupdate", that
1552 remembers when the lastcost for that vendor changed the last time.
1554 So the original values are cloned and remembered, so we can compare if lastcost
1555 was changed in $::form, and keep or update lastupdate.
1557 lastcost isn't updated until the first time it was saved with a value, until
1560 Also a boolean "makemodel" needs to be written in parts, depending on whether
1561 makemodel entries exist or not.
1563 We still need init_makemodels for when we open the part for editing.
1573 It should be possible to jump to the edit page in a specific tab
1577 Support callbacks, e.g. creating a new part from within an order, and jumping
1578 back to the order again afterwards.
1582 Support units when adding assembly items or assortment items. Currently the
1583 default unit of the item is always used.
1587 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1588 consists of other assemblies.
1594 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>