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 if ($::form->{warehouse_id} ) {
461 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
462 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
464 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
465 $self->bin($self->warehouse->bins->[0]);
467 ->html('#bin', $self->build_bin_select)
468 ->focus('#part_bin_id');
469 return $self->js->render;
473 # no warehouse was selected, empty the bin field and reset the id
475 ->val('#part_bin_id', undef)
478 return $self->js->render;
481 sub action_ajax_autocomplete {
482 my ($self, %params) = @_;
484 # if someone types something, and hits enter, assume he entered the full name.
485 # if something matches, treat that as sole match
486 # unfortunately get_models can't do more than one per package atm, so we d it
487 # the oldfashioned way.
488 if ($::form->{prefer_exact}) {
490 if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
493 SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
494 SL::DB::Manager::PartClassification->classification_filter($::form->{filter}{classification_id}),
496 description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
497 partnumber => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
502 $self->parts($exact_matches);
508 value => $_->displayable_name,
509 label => $_->displayable_name,
511 partnumber => $_->partnumber,
512 description => $_->description,
513 part_type => $_->part_type,
515 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
517 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
519 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
522 sub action_test_page {
523 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
526 sub action_part_picker_search {
527 $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
530 sub action_part_picker_result {
531 $_[0]->render('part/_part_picker_result', { layout => 0 });
537 if ($::request->type eq 'json') {
542 $part_hash = $self->part->as_tree;
543 $part_hash->{cvars} = $self->part->cvar_as_hashref;
546 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
551 sub validate_add_items {
552 scalar @{$::form->{add_items}};
555 sub prepare_assortment_render_vars {
558 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
559 items_lastcost_sum => $self->part->items_lastcost_sum,
560 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
562 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
567 sub prepare_assembly_render_vars {
570 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
571 items_lastcost_sum => $self->part->items_lastcost_sum,
572 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
574 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
582 check_has_valid_part_type($self->part->part_type);
584 $self->_set_javascript;
586 my %title_hash = ( part => t8('Add Part'),
587 assembly => t8('Add Assembly'),
588 service => t8('Add Service'),
589 assortment => t8('Add Assortment'),
594 title => $title_hash{$self->part->part_type},
595 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
600 sub _set_javascript {
602 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
603 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
606 sub recalc_item_totals {
607 my ($self, %params) = @_;
609 if ( $params{part_type} eq 'assortment' ) {
610 return 0 unless scalar @{$self->assortment_items};
611 } elsif ( $params{part_type} eq 'assembly' ) {
612 return 0 unless scalar @{$self->assembly_items};
614 carp "can only calculate sum for assortments and assemblies";
617 my $part = SL::DB::Part->new(part_type => $params{part_type});
618 if ( $part->is_assortment ) {
619 $part->assortment_items( @{$self->assortment_items} );
620 if ( $params{price_type} eq 'lastcost' ) {
621 return $part->items_lastcost_sum;
623 if ( $params{pricegroup_id} ) {
624 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
626 return $part->items_sellprice_sum;
629 } elsif ( $part->is_assembly ) {
630 $part->assemblies( @{$self->assembly_items} );
631 if ( $params{price_type} eq 'lastcost' ) {
632 return $part->items_lastcost_sum;
634 return $part->items_sellprice_sum;
639 sub check_part_not_modified {
642 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
649 my $is_new = !$self->part->id;
651 my $params = delete($::form->{part}) || { };
653 delete $params->{id};
654 # never overwrite existing partnumber, should be a read-only field anyway
655 delete $params->{partnumber} if $self->part->partnumber;
656 $self->part->assign_attributes(%{ $params});
657 $self->part->bin_id(undef) unless $self->part->warehouse_id;
659 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
660 # will be the case for used assortments when saving, or when a used assortment
662 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
663 $self->part->assortment_items([]);
664 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
667 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
668 $self->part->assemblies([]); # completely rewrite assortments each time
669 $self->part->add_assemblies( @{ $self->assembly_items } );
672 $self->part->translations([]);
673 $self->parse_form_translations;
675 $self->part->prices([]);
676 $self->parse_form_prices;
678 $self->parse_form_makemodels;
681 sub parse_form_prices {
683 # only save prices > 0
684 my $prices = delete($::form->{prices}) || [];
685 foreach my $price ( @{$prices} ) {
686 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
687 next unless $sellprice > 0; # skip negative prices as well
688 my $p = SL::DB::Price->new(parts_id => $self->part->id,
689 pricegroup_id => $price->{pricegroup_id},
692 $self->part->add_prices($p);
696 sub parse_form_translations {
698 # don't add empty translations
699 my $translations = delete($::form->{translations}) || [];
700 foreach my $translation ( @{$translations} ) {
701 next unless $translation->{translation};
702 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
703 $self->part->add_translations( $translation );
707 sub parse_form_makemodels {
711 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
712 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
715 $self->part->makemodels([]);
718 my $makemodels = delete($::form->{makemodels}) || [];
719 foreach my $makemodel ( @{$makemodels} ) {
720 next unless $makemodel->{make};
722 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
724 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
725 id => $makemodel->{id},
726 make => $makemodel->{make},
727 model => $makemodel->{model} || '',
728 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
729 sortorder => $position,
731 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
732 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
733 # don't change lastupdate
734 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
735 # new makemodel, no lastcost entered, leave lastupdate empty
736 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
737 # lastcost hasn't changed, use original lastupdate
738 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
740 $mm->lastupdate(DateTime->now);
742 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
743 $self->part->add_makemodels($mm);
747 sub build_bin_select {
748 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
749 title_key => 'description',
750 default => $_[0]->bin->id,
754 # get_set_inits for partpicker
757 if ($::form->{no_paginate}) {
758 $_[0]->models->disable_plugin('paginated');
764 # get_set_inits for part controller
768 # used by edit, save, delete and add
770 if ( $::form->{part}{id} ) {
771 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
773 die "part_type missing" unless $::form->{part}{part_type};
774 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
780 return $self->part->orphaned;
786 SL::Controller::Helper::GetModels->new(
793 partnumber => t8('Partnumber'),
794 description => t8('Description'),
796 with_objects => [ qw(unit_obj classification) ],
805 sub init_assortment_items {
806 # this init is used while saving and whenever assortments change dynamically
810 my $assortment_items = delete($::form->{assortment_items}) || [];
811 foreach my $assortment_item ( @{$assortment_items} ) {
812 next unless $assortment_item->{parts_id};
814 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
815 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
816 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
817 charge => $assortment_item->{charge},
818 unit => $assortment_item->{unit} || $part->unit,
819 position => $position,
827 sub init_makemodels {
831 my @makemodel_array = ();
832 my $makemodels = delete($::form->{makemodels}) || [];
834 foreach my $makemodel ( @{$makemodels} ) {
835 next unless $makemodel->{make};
837 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
838 id => $makemodel->{id},
839 make => $makemodel->{make},
840 model => $makemodel->{model} || '',
841 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
842 sortorder => $position,
843 ) or die "Can't create mm";
844 # $mm->id($makemodel->{id}) if $makemodel->{id};
845 push(@makemodel_array, $mm);
847 return \@makemodel_array;
850 sub init_assembly_items {
854 my $assembly_items = delete($::form->{assembly_items}) || [];
855 foreach my $assembly_item ( @{$assembly_items} ) {
856 next unless $assembly_item->{parts_id};
858 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
859 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
860 bom => $assembly_item->{bom},
861 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
862 position => $position,
869 sub init_all_warehouses {
871 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
874 sub init_all_languages {
875 SL::DB::Manager::Language->get_all_sorted;
878 sub init_all_partsgroups {
880 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
883 sub init_all_buchungsgruppen {
885 if ( $self->part->orphaned ) {
886 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
888 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
894 if ( $self->part->orphaned ) {
895 return SL::DB::Manager::Unit->get_all_sorted;
897 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
901 sub init_all_payment_terms {
903 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
906 sub init_all_price_factors {
907 SL::DB::Manager::PriceFactor->get_all_sorted;
910 sub init_all_pricegroups {
911 SL::DB::Manager::Pricegroup->get_all_sorted;
914 # model used to filter/display the parts in the multi-items dialog
915 sub init_multi_items_models {
916 SL::Controller::Helper::GetModels->new(
919 with_objects => [ qw(unit_obj partsgroup classification) ],
920 disable_plugin => 'paginated',
921 source => $::form->{multi_items},
927 partnumber => t8('Partnumber'),
928 description => t8('Description')}
932 # simple checks to run on $::form before saving
934 sub form_check_part_description_exists {
937 return 1 if $::form->{part}{description};
939 $self->js->flash('error', t8('Part Description missing!'))
940 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
941 ->focus('#part_description');
945 sub form_check_assortment_items_exist {
948 return 1 unless $::form->{part}{part_type} eq 'assortment';
949 # skip check for existing parts that have been used
950 return 1 if ($self->part->id and !$self->part->orphaned);
952 # new or orphaned parts must have items in $::form->{assortment_items}
953 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
954 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
955 ->focus('#add_assortment_item_name')
956 ->flash('error', t8('The assortment doesn\'t have any items.'));
962 sub form_check_assortment_items_unique {
965 return 1 unless $::form->{part}{part_type} eq 'assortment';
967 my %duplicate_elements;
969 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
970 $duplicate_elements{$_}++ if $count{$_}++;
973 if ( keys %duplicate_elements ) {
974 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
975 ->flash('error', t8('There are duplicate assortment items'));
981 sub form_check_assembly_items_exist {
984 return 1 unless $::form->{part}->{part_type} eq 'assembly';
986 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
987 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
988 ->focus('#add_assembly_item_name')
989 ->flash('error', t8('The assembly doesn\'t have any items.'));
995 sub form_check_partnumber_is_unique {
998 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
999 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1001 $self->js->flash('error', t8('The partnumber already exists!'))
1002 ->focus('#part_description');
1009 # general checking functions
1010 sub check_next_transnumber_is_free {
1013 my ($next_transnumber, $count);
1014 $self->part->db->with_transaction(sub {
1015 $next_transnumber = $self->part->get_next_trans_number;
1016 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1019 $count ? return 0 : return 1;
1023 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1029 $self->form_check_part_description_exists || return 0;
1030 $self->form_check_assortment_items_exist || return 0;
1031 $self->form_check_assortment_items_unique || return 0;
1032 $self->form_check_assembly_items_exist || return 0;
1033 $self->form_check_partnumber_is_unique || return 0;
1038 sub check_has_valid_part_type {
1039 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1042 sub render_assortment_items_to_html {
1043 my ($self, $assortment_items, $number_of_items) = @_;
1045 my $position = $number_of_items + 1;
1047 foreach my $ai (@$assortment_items) {
1048 $html .= $self->p->render('part/_assortment_row',
1049 PART => $self->part,
1050 orphaned => $self->orphaned,
1052 listrow => $position % 2 ? 1 : 0,
1053 position => $position, # for legacy assemblies
1060 sub render_assembly_items_to_html {
1061 my ($self, $assembly_items, $number_of_items) = @_;
1063 my $position = $number_of_items + 1;
1065 foreach my $ai (@{$assembly_items}) {
1066 $html .= $self->p->render('part/_assembly_row',
1067 PART => $self->part,
1068 orphaned => $self->orphaned,
1070 listrow => $position % 2 ? 1 : 0,
1071 position => $position, # for legacy assemblies
1078 sub parse_add_items_to_objects {
1079 my ($self, %params) = @_;
1080 my $part_type = $params{part_type};
1081 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1082 my $position = $params{position} || 1;
1084 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1087 foreach my $item ( @add_items ) {
1088 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1090 if ( $part_type eq 'assortment' ) {
1091 $ai = SL::DB::AssortmentItem->new(part => $part,
1092 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1093 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1094 position => $position,
1095 ) or die "Can't create AssortmentItem from item";
1096 } elsif ( $part_type eq 'assembly' ) {
1097 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1098 # id => $self->assembly->id, # will be set on save
1099 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1100 bom => 0, # default when adding: no bom
1101 position => $position,
1104 die "part_type must be assortment or assembly";
1106 push(@item_objects, $ai);
1110 return \@item_objects;
1121 SL::Controller::Part - Part CRUD controller
1125 Controller for adding/editing/saving/deleting parts.
1127 All the relations are loaded at once and saving the part, adding a history
1128 entry and saving CVars happens inside one transaction. When saving the old
1129 relations are deleted and written as new to the database.
1131 Relations for parts:
1139 =item assembly items
1141 =item assortment items
1149 There are 4 different part types:
1155 The "default" part type.
1157 inventory_accno_id is set.
1161 Services can't be stocked.
1163 inventory_accno_id isn't set.
1167 Assemblies consist of other parts, services, assemblies or assortments. They
1168 aren't meant to be bought, only sold. To add assemblies to stock you typically
1169 have to make them, which reduces the stock by its respective components. Once
1170 an assembly item has been created there is currently no way to "disassemble" it
1171 again. An assembly item can appear several times in one assembly. An assmbly is
1172 sold as one item with a defined sellprice and lastcost. If the component prices
1173 change the assortment price remains the same. The assembly items may be printed
1174 in a record if the item's "bom" is set.
1178 Similar to assembly, but each assortment item may only appear once per
1179 assortment. When selling an assortment the assortment items are added to the
1180 record together with the assortment, which is added with sellprice 0.
1182 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1183 determined by the sum of the current assortment item prices when the assortment
1184 is added to a record. This also means that price rules and customer discounts
1185 will be applied to the assortment items.
1187 Once the assortment items have been added they may be modified or deleted, just
1188 as if they had been added manually, the individual assortment items aren't
1189 linked to the assortment or the other assortment items in any way.
1197 =item C<action_add_part>
1199 =item C<action_add_service>
1201 =item C<action_add_assembly>
1203 =item C<action_add_assortment>
1205 =item C<action_add PART_TYPE>
1207 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1208 parameter part_type as an action. Example:
1210 controller.pl?action=Part/add&part_type=service
1212 =item C<action_save>
1214 Saves the current part and then reloads the edit page for the part.
1216 =item C<action_use_as_new>
1218 Takes the information from the current part, plus any modifications made on the
1219 page, and creates a new edit page that is ready to be saved. The partnumber is
1220 set empty, so a new partnumber from the number range will be used if the user
1221 doesn't enter one manually.
1223 Unsaved changes to the original part aren't updated.
1225 The part type cannot be changed in this way.
1227 =item C<action_delete>
1229 Deletes the current part and then redirects to the main page, there is no
1232 The delete button only appears if the part is 'orphaned', according to
1233 SL::DB::Part orphaned.
1235 The part can't be deleted if it appears in invoices, orders, delivery orders,
1236 the inventory, or is part of an assembly or assortment.
1238 If the part is deleted its relations prices, makdemodel, assembly,
1239 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1241 Before this controller items that appeared in inventory didn't count as
1242 orphaned and could be deleted and the inventory entries were also deleted, this
1243 "feature" hasn't been implemented.
1245 =item C<action_edit part.id>
1247 Load and display a part for editing.
1249 controller.pl?action=Part/edit&part.id=12345
1251 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1255 =head1 BUTTON ACTIONS
1261 Opens a popup displaying all the history entries. Once a new history controller
1262 is written the button could link there instead, with the part already selected.
1270 =item C<action_update_item_totals>
1272 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1273 amount of an item changes. The sum of all sellprices and lastcosts is
1274 calculated and the totals updated. Uses C<recalc_item_totals>.
1276 =item C<action_add_assortment_item>
1278 Adds a new assortment item from a part picker seleciton to the assortment item list
1280 If the item already exists in the assortment the item isn't added and a Flash
1283 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1284 after adding each new item, add the new object to the item objects that were
1285 already parsed, calculate totals via a dummy part then update the row and the
1288 =item C<action_add_assembly_item>
1290 Adds a new assembly item from a part picker seleciton to the assembly item list
1292 If the item already exists in the assembly a flash info is generated, but the
1295 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1296 after adding each new item, add the new object to the item objects that were
1297 already parsed, calculate totals via a dummy part then update the row and the
1300 =item C<action_add_multi_assortment_items>
1302 Parses the items to be added from the form generated by the multi input and
1303 appends the html of the tr-rows to the assortment item table. Afterwards all
1304 assortment items are renumbered and the sums recalculated via
1305 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1307 =item C<action_add_multi_assembly_items>
1309 Parses the items to be added from the form generated by the multi input and
1310 appends the html of the tr-rows to the assembly item table. Afterwards all
1311 assembly items are renumbered and the sums recalculated via
1312 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1314 =item C<action_show_multi_items_dialog>
1316 =item C<action_multi_items_update_result>
1318 =item C<action_add_makemodel_row>
1320 Add a new makemodel row with the vendor that was selected via the vendor
1323 Checks the already existing makemodels and warns if a row with that vendor
1324 already exists. Currently it is possible to have duplicate vendor rows.
1326 =item C<action_reorder_items>
1328 Sorts the item table for assembly or assortment items.
1330 =item C<action_warehouse_changed>
1334 =head1 ACTIONS part picker
1338 =item C<action_ajax_autocomplete>
1340 =item C<action_test_page>
1342 =item C<action_part_picker_search>
1344 =item C<action_part_picker_result>
1346 =item C<action_show>
1356 Calls some simple checks that test the submitted $::form for obvious errors.
1357 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1359 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1360 some cases extra actions are taken, e.g. if the part description is missing the
1361 basic data tab is selected and the description input field is focussed.
1367 =item C<form_check_part_description_exists>
1369 =item C<form_check_assortment_items_exist>
1371 =item C<form_check_assortment_items_unique>
1373 =item C<form_check_assembly_items_exist>
1375 =item C<form_check_partnumber_is_unique>
1379 =head1 HELPER FUNCTIONS
1385 When submitting the form for saving, parses the transmitted form. Expects the
1389 $::form->{makemodels}
1390 $::form->{translations}
1392 $::form->{assemblies}
1393 $::form->{assortments}
1395 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1397 =item C<recalc_item_totals %params>
1399 Helper function for calculating the total lastcost and sellprice for assemblies
1400 or assortments according to their items, which are parsed from the current
1403 Is called whenever the qty of an item is changed or items are deleted.
1407 * part_type : 'assortment' or 'assembly' (mandatory)
1409 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1411 Depending on the price_type the lastcost sum or sellprice sum is returned.
1413 Doesn't work for recursive items.
1417 =head1 GET SET INITS
1419 There are get_set_inits for
1427 which parse $::form and automatically create an array of objects.
1429 These inits are used during saving and each time a new element is added.
1433 =item C<init_makemodels>
1435 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1436 $self->part->makemodels, ready to be saved.
1438 Used for saving parts and adding new makemodel rows.
1440 =item C<parse_add_items_to_objects PART_TYPE>
1442 Parses the resulting form from either the part-picker submit or the multi-item
1443 submit, and creates an arrayref of assortment_item or assembly objects, that
1444 can be rendered via C<render_assortment_items_to_html> or
1445 C<render_assembly_items_to_html>.
1447 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1448 Optional param: position (used for numbering and listrow class)
1450 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1452 Takes an array_ref of assortment_items, and generates tables rows ready for
1453 adding to the assortment table. Is used when a part is loaded, or whenever new
1454 assortment items are added.
1456 =item C<parse_form_makemodels>
1458 Makemodels can't just be overwritten, because of the field "lastupdate", that
1459 remembers when the lastcost for that vendor changed the last time.
1461 So the original values are cloned and remembered, so we can compare if lastcost
1462 was changed in $::form, and keep or update lastupdate.
1464 lastcost isn't updated until the first time it was saved with a value, until
1467 Also a boolean "makemodel" needs to be written in parts, depending on whether
1468 makemodel entries exist or not.
1470 We still need init_makemodels for when we open the part for editing.
1480 It should be possible to jump to the edit page in a specific tab
1484 Support callbacks, e.g. creating a new part from within an order, and jumping
1485 back to the order again afterwards.
1489 Support units when adding assembly items or assortment items. Currently the
1490 default unit of the item is always used.
1494 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1495 consists of other assemblies.
1501 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>