1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::Controller::Helper::GetModels;
9 use SL::Locale::String qw(t8);
11 use List::Util qw(sum);
12 use SL::Helper::Flash;
16 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
20 use Rose::Object::MakeMethods::Generic (
21 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
24 assortment assortment_items assembly assembly_items
25 all_pricegroups all_translations all_partsgroups all_units
26 all_buchungsgruppen all_payment_terms all_warehouses
27 all_languages all_units all_price_factors) ],
28 'scalar' => [ qw(warehouse bin) ],
32 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
33 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
35 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
37 # actions for editing parts
40 my ($self, %params) = @_;
42 $self->part( SL::DB::Part->new_part );
46 sub action_add_service {
47 my ($self, %params) = @_;
49 $self->part( SL::DB::Part->new_service );
53 sub action_add_assembly {
54 my ($self, %params) = @_;
56 $self->part( SL::DB::Part->new_assembly );
60 sub action_add_assortment {
61 my ($self, %params) = @_;
63 $self->part( SL::DB::Part->new_assortment );
70 check_has_valid_part_type($::form->{part_type});
72 $self->action_add_part if $::form->{part_type} eq 'part';
73 $self->action_add_service if $::form->{part_type} eq 'service';
74 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
75 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
79 my ($self, %params) = @_;
81 # checks that depend only on submitted $::form
82 $self->check_form or return $self->js->render;
84 my $is_new = !$self->part->id; # $ part gets loaded here
86 # check that the part hasn't been modified
88 $self->check_part_not_modified or
89 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;
92 if ( $is_new and !$::form->{part}{partnumber} ) {
93 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
98 my @errors = $self->part->validate;
99 return $self->js->error(@errors)->render if @errors;
101 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
102 $self->part->db->with_transaction(sub {
104 if ( $params{save_as_new} ) {
105 $self->part( $self->part->clone_and_reset_deep );
106 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
109 $self->part->save(cascade => 1);
111 SL::DB::History->new(
112 trans_id => $self->part->id,
113 snumbers => 'partnumber_' . $self->part->partnumber,
114 employee_id => SL::DB::Manager::Employee->current->id,
119 CVar->save_custom_variables(
120 dbh => $self->part->db->dbh,
122 trans_id => $self->part->id,
123 variables => $::form, # $::form->{cvar} would be nicer
128 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
130 flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
132 # reload item, this also resets last_modification!
133 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
136 sub action_save_as_new {
138 $self->action_save(save_as_new=>1);
144 my $db = $self->part->db; # $self->part has a get_set_init on $::form
146 my $partnumber = $self->part->partnumber; # remember for history log
151 # delete part, together with relationships that don't already
152 # have an ON DELETE CASCADE, e.g. makemodel and translation.
153 $self->part->delete(cascade => 1);
155 SL::DB::History->new(
156 trans_id => $self->part->id,
157 snumbers => 'partnumber_' . $partnumber,
158 employee_id => SL::DB::Manager::Employee->current->id,
160 addition => 'DELETED',
163 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
165 flash_later('info', t8('The item has been deleted.'));
166 my @redirect_params = (
167 controller => 'controller.pl',
168 action => 'LoginScreen/user_login'
170 $self->redirect_to(@redirect_params);
173 sub action_use_as_new {
174 my ($self, %params) = @_;
176 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
177 $::form->{oldpartnumber} = $oldpart->partnumber;
179 $self->part($oldpart->clone_and_reset_deep);
181 $self->part->partnumber(undef);
187 my ($self, %params) = @_;
193 my ($self, %params) = @_;
195 $self->_set_javascript;
197 my (%assortment_vars, %assembly_vars);
198 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
199 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
201 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
203 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
204 if (scalar @{ $params{CUSTOM_VARIABLES} });
206 my %title_hash = ( part => t8('Edit Part'),
207 assembly => t8('Edit Assembly'),
208 service => t8('Edit Service'),
209 assortment => t8('Edit Assortment'),
212 $self->part->prices([]) unless $self->part->prices;
213 $self->part->translations([]) unless $self->part->translations;
217 title => $title_hash{$self->part->part_type},
218 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
221 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
222 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
223 oldpartnumber => $::form->{oldpartnumber},
224 old_id => $::form->{old_id},
232 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
233 $_[0]->render('part/history', { layout => 0 },
234 history_entries => $history_entries);
237 sub action_update_item_totals {
240 my $part_type = $::form->{part_type};
241 die unless $part_type =~ /^(assortment|assembly)$/;
243 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
244 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
246 my $sum_diff = $sellprice_sum-$lastcost_sum;
249 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
250 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
251 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
252 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
253 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
254 ->no_flash_clear->render();
257 sub action_add_multi_assortment_items {
260 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
261 my $html = $self->render_assortment_items_to_html($item_objects);
263 $self->js->run('kivi.Part.close_multi_items_dialog')
264 ->append('#assortment_rows', $html)
265 ->run('kivi.Part.renumber_positions')
266 ->run('kivi.Part.assortment_recalc')
270 sub action_add_multi_assembly_items {
273 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
275 foreach my $item (@{$item_objects}) {
276 my $errstr = validate_assembly($item->part,$self->part);
277 $self->js->flash('error',$errstr) if $errstr;
278 push (@checked_objects,$item) unless $errstr;
281 my $html = $self->render_assembly_items_to_html(\@checked_objects);
283 $self->js->run('kivi.Part.close_multi_items_dialog')
284 ->append('#assembly_rows', $html)
285 ->run('kivi.Part.renumber_positions')
286 ->run('kivi.Part.assembly_recalc')
290 sub action_add_assortment_item {
291 my ($self, %params) = @_;
293 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
295 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
297 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
298 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
299 return $self->js->flash('error', t8("This part has already been added."))->render;
302 my $number_of_items = scalar @{$self->assortment_items};
303 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
304 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
306 push(@{$self->assortment_items}, @{$item_objects});
307 my $part = SL::DB::Part->new(part_type => 'assortment');
308 $part->assortment_items(@{$self->assortment_items});
309 my $items_sellprice_sum = $part->items_sellprice_sum;
310 my $items_lastcost_sum = $part->items_lastcost_sum;
311 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
314 ->append('#assortment_rows' , $html) # append in tbody
315 ->val('.add_assortment_item_input' , '')
316 ->run('kivi.Part.focus_last_assortment_input')
317 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
318 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
319 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
320 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
321 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
325 sub action_add_assembly_item {
328 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
330 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
332 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
334 my $duplicate_warning = 0; # duplicates are allowed, just warn
335 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
336 $duplicate_warning++;
339 my $number_of_items = scalar @{$self->assembly_items};
340 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
342 foreach my $item (@{$item_objects}) {
343 my $errstr = validate_assembly($item->part,$self->part);
344 return $self->js->flash('error',$errstr)->render if $errstr;
349 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
351 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
353 push(@{$self->assembly_items}, @{$item_objects});
354 my $part = SL::DB::Part->new(part_type => 'assembly');
355 $part->assemblies(@{$self->assembly_items});
356 my $items_sellprice_sum = $part->items_sellprice_sum;
357 my $items_lastcost_sum = $part->items_lastcost_sum;
358 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
361 ->append('#assembly_rows', $html) # append in tbody
362 ->val('.add_assembly_item_input' , '')
363 ->run('kivi.Part.focus_last_assembly_input')
364 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
365 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
366 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
367 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
368 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
372 sub action_show_multi_items_dialog {
373 require SL::DB::PartsGroup;
374 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
375 part_type => 'assortment',
376 partfilter => '', # can I get at the current input of the partpicker here?
377 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
380 sub action_multi_items_update_result {
383 $::form->{multi_items}->{filter}->{obsolete} = 0;
385 my $count = $_[0]->multi_items_models->count;
388 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
389 $_[0]->render($text, { layout => 0 });
390 } elsif ($count > $max_count) {
391 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
392 $_[0]->render($text, { layout => 0 });
394 my $multi_items = $_[0]->multi_items_models->get;
395 $_[0]->render('part/_multi_items_result', { layout => 0 },
396 multi_items => $multi_items);
400 sub action_add_makemodel_row {
403 my $vendor_id = $::form->{add_makemodel};
405 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
406 return $self->js->error(t8("No vendor selected or found!"))->render;
408 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
409 $self->js->flash('info', t8("This vendor has already been added."));
412 my $position = scalar @{$self->makemodels} + 1;
414 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
418 sortorder => $position,
419 ) or die "Can't create MakeModel object";
421 my $row_as_html = $self->p->render('part/_makemodel_row',
423 listrow => $position % 2 ? 0 : 1,
426 # after selection focus on the model field in the row that was just added
428 ->append('#makemodel_rows', $row_as_html) # append in tbody
429 ->val('.add_makemodel_input', '')
430 ->run('kivi.Part.focus_last_makemodel_input')
434 sub action_reorder_items {
437 my $part_type = $::form->{part_type};
440 partnumber => sub { $_[0]->part->partnumber },
441 description => sub { $_[0]->part->description },
442 qty => sub { $_[0]->qty },
443 sellprice => sub { $_[0]->part->sellprice },
444 lastcost => sub { $_[0]->part->lastcost },
445 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
448 my $method = $sort_keys{$::form->{order_by}};
451 if ($part_type eq 'assortment') {
452 @items = @{ $self->assortment_items };
454 @items = @{ $self->assembly_items };
457 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
458 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
459 if ($::form->{sort_dir}) {
460 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
462 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
465 if ($::form->{sort_dir}) {
466 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
468 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
472 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
475 sub action_warehouse_changed {
478 if ($::form->{warehouse_id} ) {
479 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
480 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
482 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
483 $self->bin($self->warehouse->bins->[0]);
485 ->html('#bin', $self->build_bin_select)
486 ->focus('#part_bin_id');
487 return $self->js->render;
491 # no warehouse was selected, empty the bin field and reset the id
493 ->val('#part_bin_id', undef)
496 return $self->js->render;
499 sub action_ajax_autocomplete {
500 my ($self, %params) = @_;
502 # if someone types something, and hits enter, assume he entered the full name.
503 # if something matches, treat that as sole match
504 # unfortunately get_models can't do more than one per package atm, so we d it
505 # the oldfashioned way.
506 if ($::form->{prefer_exact}) {
508 if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
511 SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
512 SL::DB::Manager::PartClassification->classification_filter($::form->{filter}{classification_id}),
514 description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
515 partnumber => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
520 $self->parts($exact_matches);
526 value => $_->displayable_name,
527 label => $_->displayable_name,
529 partnumber => $_->partnumber,
530 description => $_->description,
531 part_type => $_->part_type,
533 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
535 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
537 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
540 sub action_test_page {
541 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
544 sub action_part_picker_search {
545 $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
548 sub action_part_picker_result {
549 $_[0]->render('part/_part_picker_result', { layout => 0 });
555 if ($::request->type eq 'json') {
560 $part_hash = $self->part->as_tree;
561 $part_hash->{cvars} = $self->part->cvar_as_hashref;
564 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
569 sub validate_add_items {
570 scalar @{$::form->{add_items}};
573 sub prepare_assortment_render_vars {
576 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
577 items_lastcost_sum => $self->part->items_lastcost_sum,
578 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
580 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
585 sub prepare_assembly_render_vars {
588 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
589 items_lastcost_sum => $self->part->items_lastcost_sum,
590 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
592 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
600 check_has_valid_part_type($self->part->part_type);
602 $self->_set_javascript;
604 my %title_hash = ( part => t8('Add Part'),
605 assembly => t8('Add Assembly'),
606 service => t8('Add Service'),
607 assortment => t8('Add Assortment'),
612 title => $title_hash{$self->part->part_type},
613 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
618 sub _set_javascript {
620 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
621 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
624 sub recalc_item_totals {
625 my ($self, %params) = @_;
627 if ( $params{part_type} eq 'assortment' ) {
628 return 0 unless scalar @{$self->assortment_items};
629 } elsif ( $params{part_type} eq 'assembly' ) {
630 return 0 unless scalar @{$self->assembly_items};
632 carp "can only calculate sum for assortments and assemblies";
635 my $part = SL::DB::Part->new(part_type => $params{part_type});
636 if ( $part->is_assortment ) {
637 $part->assortment_items( @{$self->assortment_items} );
638 if ( $params{price_type} eq 'lastcost' ) {
639 return $part->items_lastcost_sum;
641 if ( $params{pricegroup_id} ) {
642 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
644 return $part->items_sellprice_sum;
647 } elsif ( $part->is_assembly ) {
648 $part->assemblies( @{$self->assembly_items} );
649 if ( $params{price_type} eq 'lastcost' ) {
650 return $part->items_lastcost_sum;
652 return $part->items_sellprice_sum;
657 sub check_part_not_modified {
660 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
667 my $is_new = !$self->part->id;
669 my $params = delete($::form->{part}) || { };
671 delete $params->{id};
672 # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
673 delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
674 $self->part->assign_attributes(%{ $params});
675 $self->part->bin_id(undef) unless $self->part->warehouse_id;
677 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
678 # will be the case for used assortments when saving, or when a used assortment
680 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
681 $self->part->assortment_items([]);
682 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
685 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
686 $self->part->assemblies([]); # completely rewrite assortments each time
687 $self->part->add_assemblies( @{ $self->assembly_items } );
690 $self->part->translations([]);
691 $self->parse_form_translations;
693 $self->part->prices([]);
694 $self->parse_form_prices;
696 $self->parse_form_makemodels;
699 sub parse_form_prices {
701 # only save prices > 0
702 my $prices = delete($::form->{prices}) || [];
703 foreach my $price ( @{$prices} ) {
704 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
705 next unless $sellprice > 0; # skip negative prices as well
706 my $p = SL::DB::Price->new(parts_id => $self->part->id,
707 pricegroup_id => $price->{pricegroup_id},
710 $self->part->add_prices($p);
714 sub parse_form_translations {
716 # don't add empty translations
717 my $translations = delete($::form->{translations}) || [];
718 foreach my $translation ( @{$translations} ) {
719 next unless $translation->{translation};
720 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
721 $self->part->add_translations( $translation );
725 sub parse_form_makemodels {
729 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
730 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
733 $self->part->makemodels([]);
736 my $makemodels = delete($::form->{makemodels}) || [];
737 foreach my $makemodel ( @{$makemodels} ) {
738 next unless $makemodel->{make};
740 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
742 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
743 id => $makemodel->{id},
744 make => $makemodel->{make},
745 model => $makemodel->{model} || '',
746 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
747 sortorder => $position,
749 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
750 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
751 # don't change lastupdate
752 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
753 # new makemodel, no lastcost entered, leave lastupdate empty
754 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
755 # lastcost hasn't changed, use original lastupdate
756 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
758 $mm->lastupdate(DateTime->now);
760 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
761 $self->part->add_makemodels($mm);
765 sub build_bin_select {
766 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
767 title_key => 'description',
768 default => $_[0]->bin->id,
772 # get_set_inits for partpicker
775 if ($::form->{no_paginate}) {
776 $_[0]->models->disable_plugin('paginated');
782 # get_set_inits for part controller
786 # used by edit, save, delete and add
788 if ( $::form->{part}{id} ) {
789 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
791 die "part_type missing" unless $::form->{part}{part_type};
792 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
798 return $self->part->orphaned;
804 SL::Controller::Helper::GetModels->new(
811 partnumber => t8('Partnumber'),
812 description => t8('Description'),
814 with_objects => [ qw(unit_obj classification) ],
823 sub init_assortment_items {
824 # this init is used while saving and whenever assortments change dynamically
828 my $assortment_items = delete($::form->{assortment_items}) || [];
829 foreach my $assortment_item ( @{$assortment_items} ) {
830 next unless $assortment_item->{parts_id};
832 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
833 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
834 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
835 charge => $assortment_item->{charge},
836 unit => $assortment_item->{unit} || $part->unit,
837 position => $position,
845 sub init_makemodels {
849 my @makemodel_array = ();
850 my $makemodels = delete($::form->{makemodels}) || [];
852 foreach my $makemodel ( @{$makemodels} ) {
853 next unless $makemodel->{make};
855 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
856 id => $makemodel->{id},
857 make => $makemodel->{make},
858 model => $makemodel->{model} || '',
859 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
860 sortorder => $position,
861 ) or die "Can't create mm";
862 # $mm->id($makemodel->{id}) if $makemodel->{id};
863 push(@makemodel_array, $mm);
865 return \@makemodel_array;
868 sub init_assembly_items {
872 my $assembly_items = delete($::form->{assembly_items}) || [];
873 foreach my $assembly_item ( @{$assembly_items} ) {
874 next unless $assembly_item->{parts_id};
876 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
877 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
878 bom => $assembly_item->{bom},
879 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
880 position => $position,
887 sub init_all_warehouses {
889 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
892 sub init_all_languages {
893 SL::DB::Manager::Language->get_all_sorted;
896 sub init_all_partsgroups {
898 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
901 sub init_all_buchungsgruppen {
903 if ( $self->part->orphaned ) {
904 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
906 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
912 if ( $self->part->orphaned ) {
913 return SL::DB::Manager::Unit->get_all_sorted;
915 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
919 sub init_all_payment_terms {
921 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
924 sub init_all_price_factors {
925 SL::DB::Manager::PriceFactor->get_all_sorted;
928 sub init_all_pricegroups {
929 SL::DB::Manager::Pricegroup->get_all_sorted;
932 # model used to filter/display the parts in the multi-items dialog
933 sub init_multi_items_models {
934 SL::Controller::Helper::GetModels->new(
937 with_objects => [ qw(unit_obj partsgroup classification) ],
938 disable_plugin => 'paginated',
939 source => $::form->{multi_items},
945 partnumber => t8('Partnumber'),
946 description => t8('Description')}
950 # simple checks to run on $::form before saving
952 sub form_check_part_description_exists {
955 return 1 if $::form->{part}{description};
957 $self->js->flash('error', t8('Part Description missing!'))
958 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
959 ->focus('#part_description');
963 sub form_check_assortment_items_exist {
966 return 1 unless $::form->{part}{part_type} eq 'assortment';
967 # skip item check for existing assortments that have been used
968 return 1 if ($self->part->id and !$self->part->orphaned);
970 # new or orphaned parts must have items in $::form->{assortment_items}
971 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
972 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
973 ->focus('#add_assortment_item_name')
974 ->flash('error', t8('The assortment doesn\'t have any items.'));
980 sub form_check_assortment_items_unique {
983 return 1 unless $::form->{part}{part_type} eq 'assortment';
985 my %duplicate_elements;
987 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
988 $duplicate_elements{$_}++ if $count{$_}++;
991 if ( keys %duplicate_elements ) {
992 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
993 ->flash('error', t8('There are duplicate assortment items'));
999 sub form_check_assembly_items_exist {
1002 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1004 # skip item check for existing assembly that have been used
1005 return 1 if ($self->part->id and !$self->part->orphaned);
1007 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1008 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1009 ->focus('#add_assembly_item_name')
1010 ->flash('error', t8('The assembly doesn\'t have any items.'));
1016 sub form_check_partnumber_is_unique {
1019 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1020 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1022 $self->js->flash('error', t8('The partnumber already exists!'))
1023 ->focus('#part_description');
1030 # general checking functions
1031 sub check_next_transnumber_is_free {
1034 my ($next_transnumber, $count);
1035 $self->part->db->with_transaction(sub {
1036 $next_transnumber = $self->part->get_next_trans_number;
1037 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1040 $count ? return 0 : return 1;
1044 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1050 $self->form_check_part_description_exists || return 0;
1051 $self->form_check_assortment_items_exist || return 0;
1052 $self->form_check_assortment_items_unique || return 0;
1053 $self->form_check_assembly_items_exist || return 0;
1054 $self->form_check_partnumber_is_unique || return 0;
1059 sub check_has_valid_part_type {
1060 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1063 sub render_assortment_items_to_html {
1064 my ($self, $assortment_items, $number_of_items) = @_;
1066 my $position = $number_of_items + 1;
1068 foreach my $ai (@$assortment_items) {
1069 $html .= $self->p->render('part/_assortment_row',
1070 PART => $self->part,
1071 orphaned => $self->orphaned,
1073 listrow => $position % 2 ? 1 : 0,
1074 position => $position, # for legacy assemblies
1081 sub render_assembly_items_to_html {
1082 my ($self, $assembly_items, $number_of_items) = @_;
1084 my $position = $number_of_items + 1;
1086 foreach my $ai (@{$assembly_items}) {
1087 $html .= $self->p->render('part/_assembly_row',
1088 PART => $self->part,
1089 orphaned => $self->orphaned,
1091 listrow => $position % 2 ? 1 : 0,
1092 position => $position, # for legacy assemblies
1099 sub parse_add_items_to_objects {
1100 my ($self, %params) = @_;
1101 my $part_type = $params{part_type};
1102 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1103 my $position = $params{position} || 1;
1105 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1108 foreach my $item ( @add_items ) {
1109 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1111 if ( $part_type eq 'assortment' ) {
1112 $ai = SL::DB::AssortmentItem->new(part => $part,
1113 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1114 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1115 position => $position,
1116 ) or die "Can't create AssortmentItem from item";
1117 } elsif ( $part_type eq 'assembly' ) {
1118 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1119 # id => $self->assembly->id, # will be set on save
1120 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1121 bom => 0, # default when adding: no bom
1122 position => $position,
1125 die "part_type must be assortment or assembly";
1127 push(@item_objects, $ai);
1131 return \@item_objects;
1142 SL::Controller::Part - Part CRUD controller
1146 Controller for adding/editing/saving/deleting parts.
1148 All the relations are loaded at once and saving the part, adding a history
1149 entry and saving CVars happens inside one transaction. When saving the old
1150 relations are deleted and written as new to the database.
1152 Relations for parts:
1160 =item assembly items
1162 =item assortment items
1170 There are 4 different part types:
1176 The "default" part type.
1178 inventory_accno_id is set.
1182 Services can't be stocked.
1184 inventory_accno_id isn't set.
1188 Assemblies consist of other parts, services, assemblies or assortments. They
1189 aren't meant to be bought, only sold. To add assemblies to stock you typically
1190 have to make them, which reduces the stock by its respective components. Once
1191 an assembly item has been created there is currently no way to "disassemble" it
1192 again. An assembly item can appear several times in one assembly. An assmbly is
1193 sold as one item with a defined sellprice and lastcost. If the component prices
1194 change the assortment price remains the same. The assembly items may be printed
1195 in a record if the item's "bom" is set.
1199 Similar to assembly, but each assortment item may only appear once per
1200 assortment. When selling an assortment the assortment items are added to the
1201 record together with the assortment, which is added with sellprice 0.
1203 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1204 determined by the sum of the current assortment item prices when the assortment
1205 is added to a record. This also means that price rules and customer discounts
1206 will be applied to the assortment items.
1208 Once the assortment items have been added they may be modified or deleted, just
1209 as if they had been added manually, the individual assortment items aren't
1210 linked to the assortment or the other assortment items in any way.
1218 =item C<action_add_part>
1220 =item C<action_add_service>
1222 =item C<action_add_assembly>
1224 =item C<action_add_assortment>
1226 =item C<action_add PART_TYPE>
1228 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1229 parameter part_type as an action. Example:
1231 controller.pl?action=Part/add&part_type=service
1233 =item C<action_save>
1235 Saves the current part and then reloads the edit page for the part.
1237 =item C<action_use_as_new>
1239 Takes the information from the current part, plus any modifications made on the
1240 page, and creates a new edit page that is ready to be saved. The partnumber is
1241 set empty, so a new partnumber from the number range will be used if the user
1242 doesn't enter one manually.
1244 Unsaved changes to the original part aren't updated.
1246 The part type cannot be changed in this way.
1248 =item C<action_delete>
1250 Deletes the current part and then redirects to the main page, there is no
1253 The delete button only appears if the part is 'orphaned', according to
1254 SL::DB::Part orphaned.
1256 The part can't be deleted if it appears in invoices, orders, delivery orders,
1257 the inventory, or is part of an assembly or assortment.
1259 If the part is deleted its relations prices, makdemodel, assembly,
1260 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1262 Before this controller items that appeared in inventory didn't count as
1263 orphaned and could be deleted and the inventory entries were also deleted, this
1264 "feature" hasn't been implemented.
1266 =item C<action_edit part.id>
1268 Load and display a part for editing.
1270 controller.pl?action=Part/edit&part.id=12345
1272 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1276 =head1 BUTTON ACTIONS
1282 Opens a popup displaying all the history entries. Once a new history controller
1283 is written the button could link there instead, with the part already selected.
1291 =item C<action_update_item_totals>
1293 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1294 amount of an item changes. The sum of all sellprices and lastcosts is
1295 calculated and the totals updated. Uses C<recalc_item_totals>.
1297 =item C<action_add_assortment_item>
1299 Adds a new assortment item from a part picker seleciton to the assortment item list
1301 If the item already exists in the assortment the item isn't added and a Flash
1304 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1305 after adding each new item, add the new object to the item objects that were
1306 already parsed, calculate totals via a dummy part then update the row and the
1309 =item C<action_add_assembly_item>
1311 Adds a new assembly item from a part picker seleciton to the assembly item list
1313 If the item already exists in the assembly a flash info is generated, but the
1316 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1317 after adding each new item, add the new object to the item objects that were
1318 already parsed, calculate totals via a dummy part then update the row and the
1321 =item C<action_add_multi_assortment_items>
1323 Parses the items to be added from the form generated by the multi input and
1324 appends the html of the tr-rows to the assortment item table. Afterwards all
1325 assortment items are renumbered and the sums recalculated via
1326 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1328 =item C<action_add_multi_assembly_items>
1330 Parses the items to be added from the form generated by the multi input and
1331 appends the html of the tr-rows to the assembly item table. Afterwards all
1332 assembly items are renumbered and the sums recalculated via
1333 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1335 =item C<action_show_multi_items_dialog>
1337 =item C<action_multi_items_update_result>
1339 =item C<action_add_makemodel_row>
1341 Add a new makemodel row with the vendor that was selected via the vendor
1344 Checks the already existing makemodels and warns if a row with that vendor
1345 already exists. Currently it is possible to have duplicate vendor rows.
1347 =item C<action_reorder_items>
1349 Sorts the item table for assembly or assortment items.
1351 =item C<action_warehouse_changed>
1355 =head1 ACTIONS part picker
1359 =item C<action_ajax_autocomplete>
1361 =item C<action_test_page>
1363 =item C<action_part_picker_search>
1365 =item C<action_part_picker_result>
1367 =item C<action_show>
1377 Calls some simple checks that test the submitted $::form for obvious errors.
1378 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1380 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1381 some cases extra actions are taken, e.g. if the part description is missing the
1382 basic data tab is selected and the description input field is focussed.
1388 =item C<form_check_part_description_exists>
1390 =item C<form_check_assortment_items_exist>
1392 =item C<form_check_assortment_items_unique>
1394 =item C<form_check_assembly_items_exist>
1396 =item C<form_check_partnumber_is_unique>
1400 =head1 HELPER FUNCTIONS
1406 When submitting the form for saving, parses the transmitted form. Expects the
1410 $::form->{makemodels}
1411 $::form->{translations}
1413 $::form->{assemblies}
1414 $::form->{assortments}
1416 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1418 =item C<recalc_item_totals %params>
1420 Helper function for calculating the total lastcost and sellprice for assemblies
1421 or assortments according to their items, which are parsed from the current
1424 Is called whenever the qty of an item is changed or items are deleted.
1428 * part_type : 'assortment' or 'assembly' (mandatory)
1430 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1432 Depending on the price_type the lastcost sum or sellprice sum is returned.
1434 Doesn't work for recursive items.
1438 =head1 GET SET INITS
1440 There are get_set_inits for
1448 which parse $::form and automatically create an array of objects.
1450 These inits are used during saving and each time a new element is added.
1454 =item C<init_makemodels>
1456 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1457 $self->part->makemodels, ready to be saved.
1459 Used for saving parts and adding new makemodel rows.
1461 =item C<parse_add_items_to_objects PART_TYPE>
1463 Parses the resulting form from either the part-picker submit or the multi-item
1464 submit, and creates an arrayref of assortment_item or assembly objects, that
1465 can be rendered via C<render_assortment_items_to_html> or
1466 C<render_assembly_items_to_html>.
1468 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1469 Optional param: position (used for numbering and listrow class)
1471 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1473 Takes an array_ref of assortment_items, and generates tables rows ready for
1474 adding to the assortment table. Is used when a part is loaded, or whenever new
1475 assortment items are added.
1477 =item C<parse_form_makemodels>
1479 Makemodels can't just be overwritten, because of the field "lastupdate", that
1480 remembers when the lastcost for that vendor changed the last time.
1482 So the original values are cloned and remembered, so we can compare if lastcost
1483 was changed in $::form, and keep or update lastupdate.
1485 lastcost isn't updated until the first time it was saved with a value, until
1488 Also a boolean "makemodel" needs to be written in parts, depending on whether
1489 makemodel entries exist or not.
1491 We still need init_makemodels for when we open the part for editing.
1501 It should be possible to jump to the edit page in a specific tab
1505 Support callbacks, e.g. creating a new part from within an order, and jumping
1506 back to the order again afterwards.
1510 Support units when adding assembly items or assortment items. Currently the
1511 default unit of the item is always used.
1515 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1516 consists of other assemblies.
1522 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>