1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::Controller::Helper::GetModels;
9 use SL::Locale::String qw(t8);
11 use List::Util qw(sum);
12 use SL::Helper::Flash;
19 use Rose::Object::MakeMethods::Generic (
20 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
23 assortment assortment_items assembly assembly_items
24 all_pricegroups all_translations all_partsgroups all_units
25 all_buchungsgruppen all_payment_terms all_warehouses
26 all_languages all_units all_price_factors) ],
27 'scalar' => [ qw(warehouse bin) ],
31 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
32 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
34 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
36 # actions for editing parts
39 my ($self, %params) = @_;
41 $self->part( SL::DB::Part->new_part );
45 sub action_add_service {
46 my ($self, %params) = @_;
48 $self->part( SL::DB::Part->new_service );
52 sub action_add_assembly {
53 my ($self, %params) = @_;
55 $self->part( SL::DB::Part->new_assembly );
59 sub action_add_assortment {
60 my ($self, %params) = @_;
62 $self->part( SL::DB::Part->new_assortment );
69 check_has_valid_part_type($::form->{part_type});
71 $self->action_add_part if $::form->{part_type} eq 'part';
72 $self->action_add_service if $::form->{part_type} eq 'service';
73 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
74 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
78 my ($self, %params) = @_;
80 # checks that depend only on submitted $::form
81 $self->check_form or return $self->js->render;
83 my $is_new = !$self->part->id; # $ part gets loaded here
85 # check that the part hasn't been modified
87 $self->check_part_not_modified or
88 return $self->js->error(t8('The document has been changed by another user. Please reopen it in another window and copy the changes to the new window'))->render;
91 if ( $is_new and !$::form->{part}{partnumber} ) {
92 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
97 my @errors = $self->part->validate;
98 return $self->js->error(@errors)->render if @errors;
100 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
101 $self->part->db->with_transaction(sub {
103 if ( $params{save_as_new} ) {
104 $self->part( $self->part->clone_and_reset_deep );
105 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
108 $self->part->save(cascade => 1);
110 SL::DB::History->new(
111 trans_id => $self->part->id,
112 snumbers => 'partnumber_' . $self->part->partnumber,
113 employee_id => SL::DB::Manager::Employee->current->id,
118 CVar->save_custom_variables(
119 dbh => $self->part->db->dbh,
121 trans_id => $self->part->id,
122 variables => $::form, # $::form->{cvar} would be nicer
127 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
129 flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
131 # reload item, this also resets last_modification!
132 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
135 sub action_save_as_new {
137 $self->action_save(save_as_new=>1);
143 my $db = $self->part->db; # $self->part has a get_set_init on $::form
145 my $partnumber = $self->part->partnumber; # remember for history log
150 # delete part, together with relationships that don't already
151 # have an ON DELETE CASCADE, e.g. makemodel and translation.
152 $self->part->delete(cascade => 1);
154 SL::DB::History->new(
155 trans_id => $self->part->id,
156 snumbers => 'partnumber_' . $partnumber,
157 employee_id => SL::DB::Manager::Employee->current->id,
159 addition => 'DELETED',
162 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
164 flash_later('info', t8('The item has been deleted.'));
165 my @redirect_params = (
166 controller => 'controller.pl',
167 action => 'LoginScreen/user_login'
169 $self->redirect_to(@redirect_params);
172 sub action_use_as_new {
173 my ($self, %params) = @_;
175 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
176 $::form->{oldpartnumber} = $oldpart->partnumber;
178 $self->part($oldpart->clone_and_reset_deep);
180 $self->part->partnumber(undef);
186 my ($self, %params) = @_;
192 my ($self, %params) = @_;
194 $self->_set_javascript;
196 my (%assortment_vars, %assembly_vars);
197 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
198 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
200 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
202 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
203 if (scalar @{ $params{CUSTOM_VARIABLES} });
205 my %title_hash = ( part => t8('Edit Part'),
206 assembly => t8('Edit Assembly'),
207 service => t8('Edit Service'),
208 assortment => t8('Edit Assortment'),
211 $self->part->prices([]) unless $self->part->prices;
212 $self->part->translations([]) unless $self->part->translations;
216 title => $title_hash{$self->part->part_type},
217 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
220 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
221 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
222 oldpartnumber => $::form->{oldpartnumber},
223 old_id => $::form->{old_id},
231 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
232 $_[0]->render('part/history', { layout => 0 },
233 history_entries => $history_entries);
236 sub action_update_item_totals {
239 my $part_type = $::form->{part_type};
240 die unless $part_type =~ /^(assortment|assembly)$/;
242 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
243 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
245 my $sum_diff = $sellprice_sum-$lastcost_sum;
248 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
249 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
250 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
251 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
252 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
256 sub action_add_multi_assortment_items {
259 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
260 my $html = $self->render_assortment_items_to_html($item_objects);
262 $self->js->run('kivi.Part.close_multi_items_dialog')
263 ->append('#assortment_rows', $html)
264 ->run('kivi.Part.renumber_positions')
265 ->run('kivi.Part.assortment_recalc')
269 sub action_add_multi_assembly_items {
272 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
273 my $html = $self->render_assembly_items_to_html($item_objects);
275 $self->js->run('kivi.Part.close_multi_items_dialog')
276 ->append('#assembly_rows', $html)
277 ->run('kivi.Part.renumber_positions')
278 ->run('kivi.Part.assembly_recalc')
282 sub action_add_assortment_item {
283 my ($self, %params) = @_;
285 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
287 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
289 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
290 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
291 return $self->js->flash('error', t8("This part has already been added."))->render;
294 my $number_of_items = scalar @{$self->assortment_items};
295 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
296 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
298 push(@{$self->assortment_items}, @{$item_objects});
299 my $part = SL::DB::Part->new(part_type => 'assortment');
300 $part->assortment_items(@{$self->assortment_items});
301 my $items_sellprice_sum = $part->items_sellprice_sum;
302 my $items_lastcost_sum = $part->items_lastcost_sum;
303 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
306 ->append('#assortment_rows' , $html) # append in tbody
307 ->val('.add_assortment_item_input' , '')
308 ->run('kivi.Part.focus_last_assortment_input')
309 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
310 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
311 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
312 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
313 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
316 sub action_add_assembly_item {
319 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
321 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
323 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
324 my $duplicate_warning = 0; # duplicates are allowed, just warn
325 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
326 $duplicate_warning++;
329 my $number_of_items = scalar @{$self->assembly_items};
330 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
331 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
333 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
335 push(@{$self->assembly_items}, @{$item_objects});
336 my $part = SL::DB::Part->new(part_type => 'assembly');
337 $part->assemblies(@{$self->assembly_items});
338 my $items_sellprice_sum = $part->items_sellprice_sum;
339 my $items_lastcost_sum = $part->items_lastcost_sum;
340 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
343 ->append('#assembly_rows', $html) # append in tbody
344 ->val('.add_assembly_item_input' , '')
345 ->run('kivi.Part.focus_last_assembly_input')
346 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
347 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
348 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
349 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
350 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
354 sub action_show_multi_items_dialog {
355 require SL::DB::PartsGroup;
356 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
357 part_type => 'assortment',
358 partfilter => '', # can I get at the current input of the partpicker here?
359 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
362 sub action_multi_items_update_result {
365 $::form->{multi_items}->{filter}->{obsolete} = 0;
367 my $count = $_[0]->multi_items_models->count;
370 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
371 $_[0]->render($text, { layout => 0 });
372 } elsif ($count > $max_count) {
373 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
374 $_[0]->render($text, { layout => 0 });
376 my $multi_items = $_[0]->multi_items_models->get;
377 $_[0]->render('part/_multi_items_result', { layout => 0 },
378 multi_items => $multi_items);
382 sub action_add_makemodel_row {
385 my $vendor_id = $::form->{add_makemodel};
387 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
388 return $self->js->error(t8("No vendor selected or found!"))->render;
390 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
391 $self->js->flash('info', t8("This vendor has already been added."));
394 my $position = scalar @{$self->makemodels} + 1;
396 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
400 sortorder => $position,
401 ) or die "Can't create MakeModel object";
403 my $row_as_html = $self->p->render('part/_makemodel_row',
405 listrow => $position % 2 ? 0 : 1,
408 # after selection focus on the model field in the row that was just added
410 ->append('#makemodel_rows', $row_as_html) # append in tbody
411 ->val('.add_makemodel_input', '')
412 ->run('kivi.Part.focus_last_makemodel_input')
416 sub action_reorder_items {
419 my $part_type = $::form->{part_type};
422 partnumber => sub { $_[0]->part->partnumber },
423 description => sub { $_[0]->part->description },
424 qty => sub { $_[0]->qty },
425 sellprice => sub { $_[0]->part->sellprice },
426 lastcost => sub { $_[0]->part->lastcost },
427 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
430 my $method = $sort_keys{$::form->{order_by}};
433 if ($part_type eq 'assortment') {
434 @items = @{ $self->assortment_items };
436 @items = @{ $self->assembly_items };
439 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
440 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
441 if ($::form->{sort_dir}) {
442 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
444 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
447 if ($::form->{sort_dir}) {
448 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
450 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
454 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
457 sub action_warehouse_changed {
460 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
461 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
463 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
464 $self->bin($self->warehouse->bins->[0]);
466 ->html('#bin', $self->build_bin_select)
467 ->focus('#part_bin_id');
469 # no warehouse was selected, empty the bin field and reset the id
471 ->val('#part_bin_id', undef)
475 return $self->js->render;
478 sub action_ajax_autocomplete {
479 my ($self, %params) = @_;
481 # if someone types something, and hits enter, assume he entered the full name.
482 # if something matches, treat that as sole match
483 # unfortunately get_models can't do more than one per package atm, so we d it
484 # the oldfashioned way.
485 if ($::form->{prefer_exact}) {
487 if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
490 SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
492 description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
493 partnumber => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
498 $self->parts($exact_matches);
504 value => $_->displayable_name,
505 label => $_->displayable_name,
507 partnumber => $_->partnumber,
508 description => $_->description,
509 part_type => $_->part_type,
511 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
513 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
515 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
518 sub action_test_page {
519 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
522 sub action_part_picker_search {
523 $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
526 sub action_part_picker_result {
527 $_[0]->render('part/_part_picker_result', { layout => 0 });
533 if ($::request->type eq 'json') {
538 $part_hash = $self->part->as_tree;
539 $part_hash->{cvars} = $self->part->cvar_as_hashref;
542 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
547 sub validate_add_items {
548 scalar @{$::form->{add_items}};
551 sub prepare_assortment_render_vars {
554 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
555 items_lastcost_sum => $self->part->items_lastcost_sum,
556 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
558 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
563 sub prepare_assembly_render_vars {
566 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
567 items_lastcost_sum => $self->part->items_lastcost_sum,
568 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
570 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
578 check_has_valid_part_type($self->part->part_type);
580 $self->_set_javascript;
582 my %title_hash = ( part => t8('Add Part'),
583 assembly => t8('Add Assembly'),
584 service => t8('Add Service'),
585 assortment => t8('Add Assortment'),
590 title => $title_hash{$self->part->part_type},
591 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
596 sub _set_javascript {
598 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
599 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
602 sub recalc_item_totals {
603 my ($self, %params) = @_;
605 if ( $params{part_type} eq 'assortment' ) {
606 return 0 unless scalar @{$self->assortment_items};
607 } elsif ( $params{part_type} eq 'assembly' ) {
608 return 0 unless scalar @{$self->assembly_items};
610 carp "can only calculate sum for assortments and assemblies";
613 my $part = SL::DB::Part->new(part_type => $params{part_type});
614 if ( $part->is_assortment ) {
615 $part->assortment_items( @{$self->assortment_items} );
616 if ( $params{price_type} eq 'lastcost' ) {
617 return $part->items_lastcost_sum;
619 if ( $params{pricegroup_id} ) {
620 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
622 return $part->items_sellprice_sum;
625 } elsif ( $part->is_assembly ) {
626 $part->assemblies( @{$self->assembly_items} );
627 if ( $params{price_type} eq 'lastcost' ) {
628 return $part->items_lastcost_sum;
630 return $part->items_sellprice_sum;
635 sub check_part_not_modified {
638 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
645 my $is_new = !$self->part->id;
647 my $params = delete($::form->{part}) || { };
649 delete $params->{id};
650 # never overwrite existing partnumber, should be a read-only field anyway
651 delete $params->{partnumber} if $self->part->partnumber;
652 $self->part->assign_attributes(%{ $params});
653 $self->part->bin_id(undef) unless $self->part->warehouse_id;
655 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
656 # will be the case for used assortments when saving, or when a used assortment
658 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
659 $self->part->assortment_items([]);
660 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
663 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
664 $self->part->assemblies([]); # completely rewrite assortments each time
665 $self->part->add_assemblies( @{ $self->assembly_items } );
668 $self->part->translations([]);
669 $self->parse_form_translations;
671 $self->part->prices([]);
672 $self->parse_form_prices;
674 $self->parse_form_makemodels;
677 sub parse_form_prices {
679 # only save prices > 0
680 my $prices = delete($::form->{prices}) || [];
681 foreach my $price ( @{$prices} ) {
682 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
683 next unless $sellprice > 0; # skip negative prices as well
684 my $p = SL::DB::Price->new(parts_id => $self->part->id,
685 pricegroup_id => $price->{pricegroup_id},
688 $self->part->add_prices($p);
692 sub parse_form_translations {
694 # don't add empty translations
695 my $translations = delete($::form->{translations}) || [];
696 foreach my $translation ( @{$translations} ) {
697 next unless $translation->{translation};
698 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
699 $self->part->add_translations( $translation );
703 sub parse_form_makemodels {
707 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
708 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
711 $self->part->makemodels([]);
714 my $makemodels = delete($::form->{makemodels}) || [];
715 foreach my $makemodel ( @{$makemodels} ) {
716 next unless $makemodel->{make};
718 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
720 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
721 id => $makemodel->{id},
722 make => $makemodel->{make},
723 model => $makemodel->{model} || '',
724 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
725 sortorder => $position,
727 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
728 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
729 # don't change lastupdate
730 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
731 # new makemodel, no lastcost entered, leave lastupdate empty
732 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
733 # lastcost hasn't changed, use original lastupdate
734 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
736 $mm->lastupdate(DateTime->now);
738 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
739 $self->part->add_makemodels($mm);
743 sub build_bin_select {
744 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
745 title_key => 'description',
746 default => $_[0]->bin->id,
750 # get_set_inits for partpicker
753 if ($::form->{no_paginate}) {
754 $_[0]->models->disable_plugin('paginated');
760 # get_set_inits for part controller
764 # used by edit, save, delete and add
766 if ( $::form->{part}{id} ) {
767 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
769 die "part_type missing" unless $::form->{part}{part_type};
770 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
776 return $self->part->orphaned;
782 SL::Controller::Helper::GetModels->new(
789 partnumber => t8('Partnumber'),
790 description => t8('Description'),
792 with_objects => [ qw(unit_obj) ],
801 sub init_assortment_items {
802 # this init is used while saving and whenever assortments change dynamically
806 my $assortment_items = delete($::form->{assortment_items}) || [];
807 foreach my $assortment_item ( @{$assortment_items} ) {
808 next unless $assortment_item->{parts_id};
810 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
811 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
812 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
813 charge => $assortment_item->{charge},
814 unit => $assortment_item->{unit} || $part->unit,
815 position => $position,
823 sub init_makemodels {
827 my @makemodel_array = ();
828 my $makemodels = delete($::form->{makemodels}) || [];
830 foreach my $makemodel ( @{$makemodels} ) {
831 next unless $makemodel->{make};
833 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
834 id => $makemodel->{id},
835 make => $makemodel->{make},
836 model => $makemodel->{model} || '',
837 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
838 sortorder => $position,
839 ) or die "Can't create mm";
840 # $mm->id($makemodel->{id}) if $makemodel->{id};
841 push(@makemodel_array, $mm);
843 return \@makemodel_array;
846 sub init_assembly_items {
850 my $assembly_items = delete($::form->{assembly_items}) || [];
851 foreach my $assembly_item ( @{$assembly_items} ) {
852 next unless $assembly_item->{parts_id};
854 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
855 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
856 bom => $assembly_item->{bom},
857 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
858 position => $position,
865 sub init_all_warehouses {
867 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
870 sub init_all_languages {
871 SL::DB::Manager::Language->get_all_sorted;
874 sub init_all_partsgroups {
875 SL::DB::Manager::PartsGroup->get_all_sorted;
878 sub init_all_buchungsgruppen {
880 if ( $self->part->orphaned ) {
881 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
883 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
889 if ( $self->part->orphaned ) {
890 return SL::DB::Manager::Unit->get_all_sorted;
892 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
896 sub init_all_payment_terms {
897 SL::DB::Manager::PaymentTerm->get_all_sorted;
900 sub init_all_price_factors {
901 SL::DB::Manager::PriceFactor->get_all_sorted;
904 sub init_all_pricegroups {
905 SL::DB::Manager::Pricegroup->get_all_sorted;
908 # model used to filter/display the parts in the multi-items dialog
909 sub init_multi_items_models {
910 SL::Controller::Helper::GetModels->new(
913 with_objects => [ qw(unit_obj partsgroup) ],
914 disable_plugin => 'paginated',
915 source => $::form->{multi_items},
921 partnumber => t8('Partnumber'),
922 description => t8('Description')}
926 # simple checks to run on $::form before saving
928 sub form_check_part_description_exists {
931 return 1 if $::form->{part}{description};
933 $self->js->flash('error', t8('Part Description missing!'))
934 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
935 ->focus('#part_description');
939 sub form_check_assortment_items_exist {
942 return 1 unless $::form->{part}{part_type} eq 'assortment';
943 # skip check for existing parts that have been used
944 return 1 if ($self->part->id and !$self->part->orphaned);
946 # new or orphaned parts must have items in $::form->{assortment_items}
947 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
948 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
949 ->focus('#add_assortment_item_name')
950 ->flash('error', t8('The assortment doesn\'t have any items.'));
956 sub form_check_assortment_items_unique {
959 return 1 unless $::form->{part}{part_type} eq 'assortment';
961 my %duplicate_elements;
963 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
964 $duplicate_elements{$_}++ if $count{$_}++;
967 if ( keys %duplicate_elements ) {
968 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
969 ->flash('error', t8('There are duplicate assortment items'));
975 sub form_check_assembly_items_exist {
978 return 1 unless $::form->{part}->{part_type} eq 'assembly';
980 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
981 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
982 ->focus('#add_assembly_item_name')
983 ->flash('error', t8('The assembly doesn\'t have any items.'));
989 sub form_check_partnumber_is_unique {
992 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
993 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
995 $self->js->flash('error', t8('The partnumber already exists!'))
996 ->focus('#part_description');
1003 # general checking functions
1004 sub check_next_transnumber_is_free {
1007 my ($next_transnumber, $count);
1008 $self->part->db->with_transaction(sub {
1009 $next_transnumber = $self->part->get_next_trans_number;
1010 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1013 $count ? return 0 : return 1;
1017 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1023 $self->form_check_part_description_exists || return 0;
1024 $self->form_check_assortment_items_exist || return 0;
1025 $self->form_check_assortment_items_unique || return 0;
1026 $self->form_check_assembly_items_exist || return 0;
1027 $self->form_check_partnumber_is_unique || return 0;
1032 sub check_has_valid_part_type {
1033 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1036 sub render_assortment_items_to_html {
1037 my ($self, $assortment_items, $number_of_items) = @_;
1039 my $position = $number_of_items + 1;
1041 foreach my $ai (@$assortment_items) {
1042 $html .= $self->p->render('part/_assortment_row',
1043 PART => $self->part,
1044 orphaned => $self->orphaned,
1046 listrow => $position % 2 ? 1 : 0,
1047 position => $position, # for legacy assemblies
1054 sub render_assembly_items_to_html {
1055 my ($self, $assembly_items, $number_of_items) = @_;
1057 my $position = $number_of_items + 1;
1059 foreach my $ai (@{$assembly_items}) {
1060 $html .= $self->p->render('part/_assembly_row',
1061 PART => $self->part,
1062 orphaned => $self->orphaned,
1064 listrow => $position % 2 ? 1 : 0,
1065 position => $position, # for legacy assemblies
1072 sub parse_add_items_to_objects {
1073 my ($self, %params) = @_;
1074 my $part_type = $params{part_type};
1075 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1076 my $position = $params{position} || 1;
1078 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1081 foreach my $item ( @add_items ) {
1082 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1084 if ( $part_type eq 'assortment' ) {
1085 $ai = SL::DB::AssortmentItem->new(part => $part,
1086 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1087 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1088 position => $position,
1089 ) or die "Can't create AssortmentItem from item";
1090 } elsif ( $part_type eq 'assembly' ) {
1091 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1092 # id => $self->assembly->id, # will be set on save
1093 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1094 bom => 0, # default when adding: no bom
1095 position => $position,
1098 die "part_type must be assortment or assembly";
1100 push(@item_objects, $ai);
1104 return \@item_objects;
1115 SL::Controller::Part - Part CRUD controller
1119 Controller for adding/editing/saving/deleting parts.
1121 All the relations are loaded at once and saving the part, adding a history
1122 entry and saving CVars happens inside one transaction. When saving the old
1123 relations are deleted and written as new to the database.
1125 Relations for parts:
1133 =item assembly items
1135 =item assortment items
1143 There are 4 different part types:
1149 The "default" part type.
1151 inventory_accno_id is set.
1155 Services can't be stocked.
1157 inventory_accno_id isn't set.
1161 Assemblies consist of other parts, services, assemblies or assortments. They
1162 aren't meant to be bought, only sold. To add assemblies to stock you typically
1163 have to make them, which reduces the stock by its respective components. Once
1164 an assembly item has been created there is currently no way to "disassemble" it
1165 again. An assembly item can appear several times in one assembly. An assmbly is
1166 sold as one item with a defined sellprice and lastcost. If the component prices
1167 change the assortment price remains the same. The assembly items may be printed
1168 in a record if the item's "bom" is set.
1172 Similar to assembly, but each assortment item may only appear once per
1173 assortment. When selling an assortment the assortment items are added to the
1174 record together with the assortment, which is added with sellprice 0.
1176 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1177 determined by the sum of the current assortment item prices when the assortment
1178 is added to a record. This also means that price rules and customer discounts
1179 will be applied to the assortment items.
1181 Once the assortment items have been added they may be modified or deleted, just
1182 as if they had been added manually, the individual assortment items aren't
1183 linked to the assortment or the other assortment items in any way.
1191 =item C<action_add_part>
1193 =item C<action_add_service>
1195 =item C<action_add_assembly>
1197 =item C<action_add_assortment>
1199 =item C<action_add PART_TYPE>
1201 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1202 parameter part_type as an action. Example:
1204 controller.pl?action=Part/add&part_type=service
1206 =item C<action_save>
1208 Saves the current part and then reloads the edit page for the part.
1210 =item C<action_use_as_new>
1212 Takes the information from the current part, plus any modifications made on the
1213 page, and creates a new edit page that is ready to be saved. The partnumber is
1214 set empty, so a new partnumber from the number range will be used if the user
1215 doesn't enter one manually.
1217 Unsaved changes to the original part aren't updated.
1219 The part type cannot be changed in this way.
1221 =item C<action_delete>
1223 Deletes the current part and then redirects to the main page, there is no
1226 The delete button only appears if the part is 'orphaned', according to
1227 SL::DB::Part orphaned.
1229 The part can't be deleted if it appears in invoices, orders, delivery orders,
1230 the inventory, or is part of an assembly or assortment.
1232 If the part is deleted its relations prices, makdemodel, assembly,
1233 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1235 Before this controller items that appeared in inventory didn't count as
1236 orphaned and could be deleted and the inventory entries were also deleted, this
1237 "feature" hasn't been implemented.
1239 =item C<action_edit part.id>
1241 Load and display a part for editing.
1243 controller.pl?action=Part/edit&part.id=12345
1245 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1249 =head1 BUTTON ACTIONS
1255 Opens a popup displaying all the history entries. Once a new history controller
1256 is written the button could link there instead, with the part already selected.
1264 =item C<action_update_item_totals>
1266 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1267 amount of an item changes. The sum of all sellprices and lastcosts is
1268 calculated and the totals updated. Uses C<recalc_item_totals>.
1270 =item C<action_add_assortment_item>
1272 Adds a new assortment item from a part picker seleciton to the assortment item list
1274 If the item already exists in the assortment the item isn't added and a Flash
1277 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1278 after adding each new item, add the new object to the item objects that were
1279 already parsed, calculate totals via a dummy part then update the row and the
1282 =item C<action_add_assembly_item>
1284 Adds a new assembly item from a part picker seleciton to the assembly item list
1286 If the item already exists in the assembly a flash info is generated, but the
1289 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1290 after adding each new item, add the new object to the item objects that were
1291 already parsed, calculate totals via a dummy part then update the row and the
1294 =item C<action_add_multi_assortment_items>
1296 Parses the items to be added from the form generated by the multi input and
1297 appends the html of the tr-rows to the assortment item table. Afterwards all
1298 assortment items are renumbered and the sums recalculated via
1299 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1301 =item C<action_add_multi_assembly_items>
1303 Parses the items to be added from the form generated by the multi input and
1304 appends the html of the tr-rows to the assembly item table. Afterwards all
1305 assembly items are renumbered and the sums recalculated via
1306 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1308 =item C<action_show_multi_items_dialog>
1310 =item C<action_multi_items_update_result>
1312 =item C<action_add_makemodel_row>
1314 Add a new makemodel row with the vendor that was selected via the vendor
1317 Checks the already existing makemodels and warns if a row with that vendor
1318 already exists. Currently it is possible to have duplicate vendor rows.
1320 =item C<action_reorder_items>
1322 Sorts the item table for assembly or assortment items.
1324 =item C<action_warehouse_changed>
1328 =head1 ACTIONS part picker
1332 =item C<action_ajax_autocomplete>
1334 =item C<action_test_page>
1336 =item C<action_part_picker_search>
1338 =item C<action_part_picker_result>
1340 =item C<action_show>
1350 Calls some simple checks that test the submitted $::form for obvious errors.
1351 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1353 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1354 some cases extra actions are taken, e.g. if the part description is missing the
1355 basic data tab is selected and the description input field is focussed.
1361 =item C<form_check_part_description_exists>
1363 =item C<form_check_assortment_items_exist>
1365 =item C<form_check_assortment_items_unique>
1367 =item C<form_check_assembly_items_exist>
1369 =item C<form_check_partnumber_is_unique>
1373 =head1 HELPER FUNCTIONS
1379 When submitting the form for saving, parses the transmitted form. Expects the
1383 $::form->{makemodels}
1384 $::form->{translations}
1386 $::form->{assemblies}
1387 $::form->{assortments}
1389 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1391 =item C<recalc_item_totals %params>
1393 Helper function for calculating the total lastcost and sellprice for assemblies
1394 or assortments according to their items, which are parsed from the current
1397 Is called whenever the qty of an item is changed or items are deleted.
1401 * part_type : 'assortment' or 'assembly' (mandatory)
1403 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1405 Depending on the price_type the lastcost sum or sellprice sum is returned.
1407 Doesn't work for recursive items.
1411 =head1 GET SET INITS
1413 There are get_set_inits for
1421 which parse $::form and automatically create an array of objects.
1423 These inits are used during saving and each time a new element is added.
1427 =item C<init_makemodels>
1429 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1430 $self->part->makemodels, ready to be saved.
1432 Used for saving parts and adding new makemodel rows.
1434 =item C<parse_add_items_to_objects PART_TYPE>
1436 Parses the resulting form from either the part-picker submit or the multi-item
1437 submit, and creates an arrayref of assortment_item or assembly objects, that
1438 can be rendered via C<render_assortment_items_to_html> or
1439 C<render_assembly_items_to_html>.
1441 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1442 Optional param: position (used for numbering and listrow class)
1444 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1446 Takes an array_ref of assortment_items, and generates tables rows ready for
1447 adding to the assortment table. Is used when a part is loaded, or whenever new
1448 assortment items are added.
1450 =item C<parse_form_makemodels>
1452 Makemodels can't just be overwritten, because of the field "lastupdate", that
1453 remembers when the lastcost for that vendor changed the last time.
1455 So the original values are cloned and remembered, so we can compare if lastcost
1456 was changed in $::form, and keep or update lastupdate.
1458 lastcost isn't updated until the first time it was saved with a value, until
1461 Also a boolean "makemodel" needs to be written in parts, depending on whether
1462 makemodel entries exist or not.
1464 We still need init_makemodels for when we open the part for editing.
1474 It should be possible to jump to the edit page in a specific tab
1478 Support callbacks, e.g. creating a new part from within an order, and jumping
1479 back to the order again afterwards.
1483 Support units when adding assembly items or assortment items. Currently the
1484 default unit of the item is always used.
1488 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1489 consists of other assemblies.
1495 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>