1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
9 use SL::Controller::Helper::GetModels;
10 use SL::Locale::String qw(t8);
12 use List::Util qw(sum);
13 use SL::Helper::Flash;
17 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
21 use Rose::Object::MakeMethods::Generic (
22 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
25 assortment assortment_items assembly assembly_items
26 all_pricegroups all_translations all_partsgroups all_units
27 all_buchungsgruppen all_payment_terms all_warehouses
28 all_languages all_units all_price_factors) ],
29 'scalar' => [ qw(warehouse bin) ],
33 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
34 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
36 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
38 # actions for editing parts
41 my ($self, %params) = @_;
43 $::form->{callback} = $self->url_for(action => 'add_part') unless $::form->{callback};
44 $self->part( SL::DB::Part->new_part );
48 sub action_add_service {
49 my ($self, %params) = @_;
51 $::form->{callback} = $self->url_for(action => 'add_service') unless $::form->{callback};
52 $self->part( SL::DB::Part->new_service );
56 sub action_add_assembly {
57 my ($self, %params) = @_;
59 $::form->{callback} = $self->url_for(action => 'add_assembly') unless $::form->{callback};
60 $self->part( SL::DB::Part->new_assembly );
64 sub action_add_assortment {
65 my ($self, %params) = @_;
67 $::form->{callback} = $self->url_for(action => 'add_assortment') unless $::form->{callback};
68 $self->part( SL::DB::Part->new_assortment );
75 check_has_valid_part_type($::form->{part_type});
77 $self->action_add_part if $::form->{part_type} eq 'part';
78 $self->action_add_service if $::form->{part_type} eq 'service';
79 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
80 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
84 my ($self, %params) = @_;
86 # checks that depend only on submitted $::form
87 $self->check_form or return $self->js->render;
89 my $is_new = !$self->part->id; # $ part gets loaded here
91 # check that the part hasn't been modified
93 $self->check_part_not_modified or
94 return $self->js->error(t8('The document has been changed by another user. Please reopen it in another window and copy the changes to the new window'))->render;
98 && $::form->{part}{partnumber}
99 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
101 return $self->js->error(t8('The partnumber is already being used'))->render;
106 my @errors = $self->part->validate;
107 return $self->js->error(@errors)->render if @errors;
109 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
110 $self->part->db->with_transaction(sub {
112 if ( $params{save_as_new} ) {
113 $self->part( $self->part->clone_and_reset_deep );
114 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
117 $self->part->save(cascade => 1);
119 SL::DB::History->new(
120 trans_id => $self->part->id,
121 snumbers => 'partnumber_' . $self->part->partnumber,
122 employee_id => SL::DB::Manager::Employee->current->id,
127 CVar->save_custom_variables(
128 dbh => $self->part->db->dbh,
130 trans_id => $self->part->id,
131 variables => $::form, # $::form->{cvar} would be nicer
136 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
139 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
141 if ( $::form->{callback} ) {
142 $self->redirect_to($::form->unescape($::form->{callback}));
144 # default behaviour after save: reload item, this also resets last_modification!
145 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
149 sub action_save_as_new {
151 $self->action_save(save_as_new=>1);
157 my $db = $self->part->db; # $self->part has a get_set_init on $::form
159 my $partnumber = $self->part->partnumber; # remember for history log
164 # delete part, together with relationships that don't already
165 # have an ON DELETE CASCADE, e.g. makemodel and translation.
166 $self->part->delete(cascade => 1);
168 SL::DB::History->new(
169 trans_id => $self->part->id,
170 snumbers => 'partnumber_' . $partnumber,
171 employee_id => SL::DB::Manager::Employee->current->id,
173 addition => 'DELETED',
176 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
178 flash_later('info', t8('The item has been deleted.'));
179 if ( $::form->{callback} ) {
180 $self->redirect_to($::form->unescape($::form->{callback}));
182 my @redirect_params = (
183 controller => 'controller.pl',
184 action => 'LoginScreen/user_login'
186 $self->redirect_to(@redirect_params);
190 sub action_use_as_new {
191 my ($self, %params) = @_;
193 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
194 $::form->{oldpartnumber} = $oldpart->partnumber;
196 $self->part($oldpart->clone_and_reset_deep);
198 $self->part->partnumber(undef);
204 my ($self, %params) = @_;
210 my ($self, %params) = @_;
212 $self->_set_javascript;
213 $self->_setup_form_action_bar;
215 my (%assortment_vars, %assembly_vars);
216 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
217 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
219 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
221 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
222 if (scalar @{ $params{CUSTOM_VARIABLES} });
224 my %title_hash = ( part => t8('Edit Part'),
225 assembly => t8('Edit Assembly'),
226 service => t8('Edit Service'),
227 assortment => t8('Edit Assortment'),
230 $self->part->prices([]) unless $self->part->prices;
231 $self->part->translations([]) unless $self->part->translations;
235 title => $title_hash{$self->part->part_type},
238 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
239 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
240 oldpartnumber => $::form->{oldpartnumber},
241 old_id => $::form->{old_id},
249 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
250 $_[0]->render('part/history', { layout => 0 },
251 history_entries => $history_entries);
254 sub action_update_item_totals {
257 my $part_type = $::form->{part_type};
258 die unless $part_type =~ /^(assortment|assembly)$/;
260 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
261 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
263 my $sum_diff = $sellprice_sum-$lastcost_sum;
266 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
267 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
268 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
269 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
270 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
271 ->no_flash_clear->render();
274 sub action_add_multi_assortment_items {
277 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
278 my $html = $self->render_assortment_items_to_html($item_objects);
280 $self->js->run('kivi.Part.close_picker_dialogs')
281 ->append('#assortment_rows', $html)
282 ->run('kivi.Part.renumber_positions')
283 ->run('kivi.Part.assortment_recalc')
287 sub action_add_multi_assembly_items {
290 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
292 foreach my $item (@{$item_objects}) {
293 my $errstr = validate_assembly($item->part,$self->part);
294 $self->js->flash('error',$errstr) if $errstr;
295 push (@checked_objects,$item) unless $errstr;
298 my $html = $self->render_assembly_items_to_html(\@checked_objects);
300 $self->js->run('kivi.Part.close_picker_dialogs')
301 ->append('#assembly_rows', $html)
302 ->run('kivi.Part.renumber_positions')
303 ->run('kivi.Part.assembly_recalc')
307 sub action_add_assortment_item {
308 my ($self, %params) = @_;
310 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
312 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
314 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
315 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
316 return $self->js->flash('error', t8("This part has already been added."))->render;
319 my $number_of_items = scalar @{$self->assortment_items};
320 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
321 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
323 push(@{$self->assortment_items}, @{$item_objects});
324 my $part = SL::DB::Part->new(part_type => 'assortment');
325 $part->assortment_items(@{$self->assortment_items});
326 my $items_sellprice_sum = $part->items_sellprice_sum;
327 my $items_lastcost_sum = $part->items_lastcost_sum;
328 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
331 ->append('#assortment_rows' , $html) # append in tbody
332 ->val('.add_assortment_item_input' , '')
333 ->run('kivi.Part.focus_last_assortment_input')
334 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
335 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
336 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
337 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
338 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
342 sub action_add_assembly_item {
345 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
347 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
349 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
351 my $duplicate_warning = 0; # duplicates are allowed, just warn
352 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
353 $duplicate_warning++;
356 my $number_of_items = scalar @{$self->assembly_items};
357 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
359 foreach my $item (@{$item_objects}) {
360 my $errstr = validate_assembly($item->part,$self->part);
361 return $self->js->flash('error',$errstr)->render if $errstr;
366 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
368 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
370 push(@{$self->assembly_items}, @{$item_objects});
371 my $part = SL::DB::Part->new(part_type => 'assembly');
372 $part->assemblies(@{$self->assembly_items});
373 my $items_sellprice_sum = $part->items_sellprice_sum;
374 my $items_lastcost_sum = $part->items_lastcost_sum;
375 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
378 ->append('#assembly_rows', $html) # append in tbody
379 ->val('.add_assembly_item_input' , '')
380 ->run('kivi.Part.focus_last_assembly_input')
381 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
382 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
383 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
384 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
385 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
389 sub action_show_multi_items_dialog {
390 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
391 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
395 sub action_multi_items_update_result {
398 $::form->{multi_items}->{filter}->{obsolete} = 0;
400 my $count = $_[0]->multi_items_models->count;
403 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
404 $_[0]->render($text, { layout => 0 });
405 } elsif ($count > $max_count) {
406 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
407 $_[0]->render($text, { layout => 0 });
409 my $multi_items = $_[0]->multi_items_models->get;
410 $_[0]->render('part/_multi_items_result', { layout => 0 },
411 multi_items => $multi_items);
415 sub action_add_makemodel_row {
418 my $vendor_id = $::form->{add_makemodel};
420 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
421 return $self->js->error(t8("No vendor selected or found!"))->render;
423 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
424 $self->js->flash('info', t8("This vendor has already been added."));
427 my $position = scalar @{$self->makemodels} + 1;
429 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
433 sortorder => $position,
434 ) or die "Can't create MakeModel object";
436 my $row_as_html = $self->p->render('part/_makemodel_row',
438 listrow => $position % 2 ? 0 : 1,
441 # after selection focus on the model field in the row that was just added
443 ->append('#makemodel_rows', $row_as_html) # append in tbody
444 ->val('.add_makemodel_input', '')
445 ->run('kivi.Part.focus_last_makemodel_input')
449 sub action_reorder_items {
452 my $part_type = $::form->{part_type};
455 partnumber => sub { $_[0]->part->partnumber },
456 description => sub { $_[0]->part->description },
457 qty => sub { $_[0]->qty },
458 sellprice => sub { $_[0]->part->sellprice },
459 lastcost => sub { $_[0]->part->lastcost },
460 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
463 my $method = $sort_keys{$::form->{order_by}};
466 if ($part_type eq 'assortment') {
467 @items = @{ $self->assortment_items };
469 @items = @{ $self->assembly_items };
472 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
473 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
474 if ($::form->{sort_dir}) {
475 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
477 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
480 if ($::form->{sort_dir}) {
481 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
483 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
487 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
490 sub action_warehouse_changed {
493 if ($::form->{warehouse_id} ) {
494 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
495 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
497 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
498 $self->bin($self->warehouse->bins->[0]);
500 ->html('#bin', $self->build_bin_select)
501 ->focus('#part_bin_id');
502 return $self->js->render;
506 # no warehouse was selected, empty the bin field and reset the id
508 ->val('#part_bin_id', undef)
511 return $self->js->render;
514 sub action_ajax_autocomplete {
515 my ($self, %params) = @_;
517 # if someone types something, and hits enter, assume he entered the full name.
518 # if something matches, treat that as sole match
519 # since we need a second get models instance with different filters for that,
520 # we only modify the original filter temporarily in place
521 if ($::form->{prefer_exact}) {
522 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
524 my $exact_models = SL::Controller::Helper::GetModels->new(
527 paginated => { per_page => 2 },
528 with_objects => [ qw(unit_obj classification) ],
531 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
532 $self->parts($exact_matches);
538 value => $_->displayable_name,
539 label => $_->displayable_name,
541 partnumber => $_->partnumber,
542 description => $_->description,
543 part_type => $_->part_type,
545 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
547 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
549 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
552 sub action_test_page {
553 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
556 sub action_part_picker_search {
557 $_[0]->render('part/part_picker_search', { layout => 0 });
560 sub action_part_picker_result {
561 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
567 if ($::request->type eq 'json') {
572 $part_hash = $self->part->as_tree;
573 $part_hash->{cvars} = $self->part->cvar_as_hashref;
576 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
581 sub validate_add_items {
582 scalar @{$::form->{add_items}};
585 sub prepare_assortment_render_vars {
588 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
589 items_lastcost_sum => $self->part->items_lastcost_sum,
590 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
592 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
597 sub prepare_assembly_render_vars {
600 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
601 items_lastcost_sum => $self->part->items_lastcost_sum,
602 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
604 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
612 check_has_valid_part_type($self->part->part_type);
614 $self->_set_javascript;
615 $self->_setup_form_action_bar;
617 my %title_hash = ( part => t8('Add Part'),
618 assembly => t8('Add Assembly'),
619 service => t8('Add Service'),
620 assortment => t8('Add Assortment'),
625 title => $title_hash{$self->part->part_type},
630 sub _set_javascript {
632 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
633 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
636 sub recalc_item_totals {
637 my ($self, %params) = @_;
639 if ( $params{part_type} eq 'assortment' ) {
640 return 0 unless scalar @{$self->assortment_items};
641 } elsif ( $params{part_type} eq 'assembly' ) {
642 return 0 unless scalar @{$self->assembly_items};
644 carp "can only calculate sum for assortments and assemblies";
647 my $part = SL::DB::Part->new(part_type => $params{part_type});
648 if ( $part->is_assortment ) {
649 $part->assortment_items( @{$self->assortment_items} );
650 if ( $params{price_type} eq 'lastcost' ) {
651 return $part->items_lastcost_sum;
653 if ( $params{pricegroup_id} ) {
654 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
656 return $part->items_sellprice_sum;
659 } elsif ( $part->is_assembly ) {
660 $part->assemblies( @{$self->assembly_items} );
661 if ( $params{price_type} eq 'lastcost' ) {
662 return $part->items_lastcost_sum;
664 return $part->items_sellprice_sum;
669 sub check_part_not_modified {
672 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
679 my $is_new = !$self->part->id;
681 my $params = delete($::form->{part}) || { };
683 delete $params->{id};
684 # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
685 delete $params->{partnumber} if $self->part->partnumber and $self->part->used_in_record;
686 $self->part->assign_attributes(%{ $params});
687 $self->part->bin_id(undef) unless $self->part->warehouse_id;
689 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
690 # will be the case for used assortments when saving, or when a used assortment
692 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
693 $self->part->assortment_items([]);
694 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
697 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
698 $self->part->assemblies([]); # completely rewrite assortments each time
699 $self->part->add_assemblies( @{ $self->assembly_items } );
702 $self->part->translations([]);
703 $self->parse_form_translations;
705 $self->part->prices([]);
706 $self->parse_form_prices;
708 $self->parse_form_makemodels;
711 sub parse_form_prices {
713 # only save prices > 0
714 my $prices = delete($::form->{prices}) || [];
715 foreach my $price ( @{$prices} ) {
716 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
717 next unless $sellprice > 0; # skip negative prices as well
718 my $p = SL::DB::Price->new(parts_id => $self->part->id,
719 pricegroup_id => $price->{pricegroup_id},
722 $self->part->add_prices($p);
726 sub parse_form_translations {
728 # don't add empty translations
729 my $translations = delete($::form->{translations}) || [];
730 foreach my $translation ( @{$translations} ) {
731 next unless $translation->{translation};
732 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
733 $self->part->add_translations( $translation );
737 sub parse_form_makemodels {
741 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
742 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
745 $self->part->makemodels([]);
748 my $makemodels = delete($::form->{makemodels}) || [];
749 foreach my $makemodel ( @{$makemodels} ) {
750 next unless $makemodel->{make};
752 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
754 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
755 id => $makemodel->{id},
756 make => $makemodel->{make},
757 model => $makemodel->{model} || '',
758 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
759 sortorder => $position,
761 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
762 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
763 # don't change lastupdate
764 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
765 # new makemodel, no lastcost entered, leave lastupdate empty
766 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
767 # lastcost hasn't changed, use original lastupdate
768 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
770 $mm->lastupdate(DateTime->now);
772 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
773 $self->part->add_makemodels($mm);
777 sub build_bin_select {
778 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
779 title_key => 'description',
780 default => $_[0]->bin->id,
784 # get_set_inits for partpicker
787 if ($::form->{no_paginate}) {
788 $_[0]->models->disable_plugin('paginated');
794 # get_set_inits for part controller
798 # used by edit, save, delete and add
800 if ( $::form->{part}{id} ) {
801 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
803 die "part_type missing" unless $::form->{part}{part_type};
804 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
810 return $self->part->orphaned;
816 SL::Controller::Helper::GetModels->new(
823 partnumber => t8('Partnumber'),
824 description => t8('Description'),
826 with_objects => [ qw(unit_obj classification) ],
835 sub init_assortment_items {
836 # this init is used while saving and whenever assortments change dynamically
840 my $assortment_items = delete($::form->{assortment_items}) || [];
841 foreach my $assortment_item ( @{$assortment_items} ) {
842 next unless $assortment_item->{parts_id};
844 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
845 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
846 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
847 charge => $assortment_item->{charge},
848 unit => $assortment_item->{unit} || $part->unit,
849 position => $position,
857 sub init_makemodels {
861 my @makemodel_array = ();
862 my $makemodels = delete($::form->{makemodels}) || [];
864 foreach my $makemodel ( @{$makemodels} ) {
865 next unless $makemodel->{make};
867 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
868 id => $makemodel->{id},
869 make => $makemodel->{make},
870 model => $makemodel->{model} || '',
871 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
872 sortorder => $position,
873 ) or die "Can't create mm";
874 # $mm->id($makemodel->{id}) if $makemodel->{id};
875 push(@makemodel_array, $mm);
877 return \@makemodel_array;
880 sub init_assembly_items {
884 my $assembly_items = delete($::form->{assembly_items}) || [];
885 foreach my $assembly_item ( @{$assembly_items} ) {
886 next unless $assembly_item->{parts_id};
888 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
889 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
890 bom => $assembly_item->{bom},
891 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
892 position => $position,
899 sub init_all_warehouses {
901 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
904 sub init_all_languages {
905 SL::DB::Manager::Language->get_all_sorted;
908 sub init_all_partsgroups {
910 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
913 sub init_all_buchungsgruppen {
915 if ( $self->part->orphaned ) {
916 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
918 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
924 if ( $self->part->orphaned ) {
925 return SL::DB::Manager::Unit->get_all_sorted;
927 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
931 sub init_all_payment_terms {
933 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
936 sub init_all_price_factors {
937 SL::DB::Manager::PriceFactor->get_all_sorted;
940 sub init_all_pricegroups {
941 SL::DB::Manager::Pricegroup->get_all_sorted;
944 # model used to filter/display the parts in the multi-items dialog
945 sub init_multi_items_models {
946 SL::Controller::Helper::GetModels->new(
949 with_objects => [ qw(unit_obj partsgroup classification) ],
950 disable_plugin => 'paginated',
951 source => $::form->{multi_items},
957 partnumber => t8('Partnumber'),
958 description => t8('Description')}
962 # simple checks to run on $::form before saving
964 sub form_check_part_description_exists {
967 return 1 if $::form->{part}{description};
969 $self->js->flash('error', t8('Part Description missing!'))
970 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
971 ->focus('#part_description');
975 sub form_check_assortment_items_exist {
978 return 1 unless $::form->{part}{part_type} eq 'assortment';
979 # skip item check for existing assortments that have been used
980 return 1 if ($self->part->id and !$self->part->orphaned);
982 # new or orphaned parts must have items in $::form->{assortment_items}
983 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
984 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
985 ->focus('#add_assortment_item_name')
986 ->flash('error', t8('The assortment doesn\'t have any items.'));
992 sub form_check_assortment_items_unique {
995 return 1 unless $::form->{part}{part_type} eq 'assortment';
997 my %duplicate_elements;
999 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1000 $duplicate_elements{$_}++ if $count{$_}++;
1003 if ( keys %duplicate_elements ) {
1004 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1005 ->flash('error', t8('There are duplicate assortment items'));
1011 sub form_check_assembly_items_exist {
1014 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1016 # skip item check for existing assembly that have been used
1017 return 1 if ($self->part->id and !$self->part->orphaned);
1019 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1020 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1021 ->focus('#add_assembly_item_name')
1022 ->flash('error', t8('The assembly doesn\'t have any items.'));
1028 sub form_check_partnumber_is_unique {
1031 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1032 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1034 $self->js->flash('error', t8('The partnumber already exists!'))
1035 ->focus('#part_description');
1042 # general checking functions
1045 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1051 $self->form_check_part_description_exists || return 0;
1052 $self->form_check_assortment_items_exist || return 0;
1053 $self->form_check_assortment_items_unique || return 0;
1054 $self->form_check_assembly_items_exist || return 0;
1055 $self->form_check_partnumber_is_unique || return 0;
1060 sub check_has_valid_part_type {
1061 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1064 sub render_assortment_items_to_html {
1065 my ($self, $assortment_items, $number_of_items) = @_;
1067 my $position = $number_of_items + 1;
1069 foreach my $ai (@$assortment_items) {
1070 $html .= $self->p->render('part/_assortment_row',
1071 PART => $self->part,
1072 orphaned => $self->orphaned,
1074 listrow => $position % 2 ? 1 : 0,
1075 position => $position, # for legacy assemblies
1082 sub render_assembly_items_to_html {
1083 my ($self, $assembly_items, $number_of_items) = @_;
1085 my $position = $number_of_items + 1;
1087 foreach my $ai (@{$assembly_items}) {
1088 $html .= $self->p->render('part/_assembly_row',
1089 PART => $self->part,
1090 orphaned => $self->orphaned,
1092 listrow => $position % 2 ? 1 : 0,
1093 position => $position, # for legacy assemblies
1100 sub parse_add_items_to_objects {
1101 my ($self, %params) = @_;
1102 my $part_type = $params{part_type};
1103 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1104 my $position = $params{position} || 1;
1106 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1109 foreach my $item ( @add_items ) {
1110 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1112 if ( $part_type eq 'assortment' ) {
1113 $ai = SL::DB::AssortmentItem->new(part => $part,
1114 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1115 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1116 position => $position,
1117 ) or die "Can't create AssortmentItem from item";
1118 } elsif ( $part_type eq 'assembly' ) {
1119 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1120 # id => $self->assembly->id, # will be set on save
1121 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1122 bom => 0, # default when adding: no bom
1123 position => $position,
1126 die "part_type must be assortment or assembly";
1128 push(@item_objects, $ai);
1132 return \@item_objects;
1135 sub _setup_form_action_bar {
1138 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1140 for my $bar ($::request->layout->get('actionbar')) {
1145 call => [ 'kivi.Part.save' ],
1146 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1147 accesskey => 'enter',
1151 call => [ 'kivi.Part.use_as_new' ],
1152 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1153 : !$may_edit ? t8('You do not have the permissions to access this function.')
1156 ], # end of combobox "Save"
1160 call => [ 'kivi.Part.delete' ],
1161 confirm => t8('Do you really want to delete this object?'),
1162 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1163 : !$may_edit ? t8('You do not have the permissions to access this function.')
1164 : !$self->part->orphaned ? t8('This object has already been used.')
1172 call => [ 'kivi.Part.open_history_popup' ],
1173 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1174 : !$may_edit ? t8('You do not have the permissions to access this function.')
1189 SL::Controller::Part - Part CRUD controller
1193 Controller for adding/editing/saving/deleting parts.
1195 All the relations are loaded at once and saving the part, adding a history
1196 entry and saving CVars happens inside one transaction. When saving the old
1197 relations are deleted and written as new to the database.
1199 Relations for parts:
1207 =item assembly items
1209 =item assortment items
1217 There are 4 different part types:
1223 The "default" part type.
1225 inventory_accno_id is set.
1229 Services can't be stocked.
1231 inventory_accno_id isn't set.
1235 Assemblies consist of other parts, services, assemblies or assortments. They
1236 aren't meant to be bought, only sold. To add assemblies to stock you typically
1237 have to make them, which reduces the stock by its respective components. Once
1238 an assembly item has been created there is currently no way to "disassemble" it
1239 again. An assembly item can appear several times in one assembly. An assmbly is
1240 sold as one item with a defined sellprice and lastcost. If the component prices
1241 change the assortment price remains the same. The assembly items may be printed
1242 in a record if the item's "bom" is set.
1246 Similar to assembly, but each assortment item may only appear once per
1247 assortment. When selling an assortment the assortment items are added to the
1248 record together with the assortment, which is added with sellprice 0.
1250 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1251 determined by the sum of the current assortment item prices when the assortment
1252 is added to a record. This also means that price rules and customer discounts
1253 will be applied to the assortment items.
1255 Once the assortment items have been added they may be modified or deleted, just
1256 as if they had been added manually, the individual assortment items aren't
1257 linked to the assortment or the other assortment items in any way.
1265 =item C<action_add_part>
1267 =item C<action_add_service>
1269 =item C<action_add_assembly>
1271 =item C<action_add_assortment>
1273 =item C<action_add PART_TYPE>
1275 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1276 parameter part_type as an action. Example:
1278 controller.pl?action=Part/add&part_type=service
1280 =item C<action_save>
1282 Saves the current part and then reloads the edit page for the part.
1284 =item C<action_use_as_new>
1286 Takes the information from the current part, plus any modifications made on the
1287 page, and creates a new edit page that is ready to be saved. The partnumber is
1288 set empty, so a new partnumber from the number range will be used if the user
1289 doesn't enter one manually.
1291 Unsaved changes to the original part aren't updated.
1293 The part type cannot be changed in this way.
1295 =item C<action_delete>
1297 Deletes the current part and then redirects to the main page, there is no
1300 The delete button only appears if the part is 'orphaned', according to
1301 SL::DB::Part orphaned.
1303 The part can't be deleted if it appears in invoices, orders, delivery orders,
1304 the inventory, or is part of an assembly or assortment.
1306 If the part is deleted its relations prices, makdemodel, assembly,
1307 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1309 Before this controller items that appeared in inventory didn't count as
1310 orphaned and could be deleted and the inventory entries were also deleted, this
1311 "feature" hasn't been implemented.
1313 =item C<action_edit part.id>
1315 Load and display a part for editing.
1317 controller.pl?action=Part/edit&part.id=12345
1319 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1323 =head1 BUTTON ACTIONS
1329 Opens a popup displaying all the history entries. Once a new history controller
1330 is written the button could link there instead, with the part already selected.
1338 =item C<action_update_item_totals>
1340 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1341 amount of an item changes. The sum of all sellprices and lastcosts is
1342 calculated and the totals updated. Uses C<recalc_item_totals>.
1344 =item C<action_add_assortment_item>
1346 Adds a new assortment item from a part picker seleciton to the assortment item list
1348 If the item already exists in the assortment the item isn't added and a Flash
1351 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1352 after adding each new item, add the new object to the item objects that were
1353 already parsed, calculate totals via a dummy part then update the row and the
1356 =item C<action_add_assembly_item>
1358 Adds a new assembly item from a part picker seleciton to the assembly item list
1360 If the item already exists in the assembly a flash info is generated, but the
1363 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1364 after adding each new item, add the new object to the item objects that were
1365 already parsed, calculate totals via a dummy part then update the row and the
1368 =item C<action_add_multi_assortment_items>
1370 Parses the items to be added from the form generated by the multi input and
1371 appends the html of the tr-rows to the assortment item table. Afterwards all
1372 assortment items are renumbered and the sums recalculated via
1373 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1375 =item C<action_add_multi_assembly_items>
1377 Parses the items to be added from the form generated by the multi input and
1378 appends the html of the tr-rows to the assembly item table. Afterwards all
1379 assembly items are renumbered and the sums recalculated via
1380 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1382 =item C<action_show_multi_items_dialog>
1384 =item C<action_multi_items_update_result>
1386 =item C<action_add_makemodel_row>
1388 Add a new makemodel row with the vendor that was selected via the vendor
1391 Checks the already existing makemodels and warns if a row with that vendor
1392 already exists. Currently it is possible to have duplicate vendor rows.
1394 =item C<action_reorder_items>
1396 Sorts the item table for assembly or assortment items.
1398 =item C<action_warehouse_changed>
1402 =head1 ACTIONS part picker
1406 =item C<action_ajax_autocomplete>
1408 =item C<action_test_page>
1410 =item C<action_part_picker_search>
1412 =item C<action_part_picker_result>
1414 =item C<action_show>
1424 Calls some simple checks that test the submitted $::form for obvious errors.
1425 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1427 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1428 some cases extra actions are taken, e.g. if the part description is missing the
1429 basic data tab is selected and the description input field is focussed.
1435 =item C<form_check_part_description_exists>
1437 =item C<form_check_assortment_items_exist>
1439 =item C<form_check_assortment_items_unique>
1441 =item C<form_check_assembly_items_exist>
1443 =item C<form_check_partnumber_is_unique>
1447 =head1 HELPER FUNCTIONS
1453 When submitting the form for saving, parses the transmitted form. Expects the
1457 $::form->{makemodels}
1458 $::form->{translations}
1460 $::form->{assemblies}
1461 $::form->{assortments}
1463 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1465 =item C<recalc_item_totals %params>
1467 Helper function for calculating the total lastcost and sellprice for assemblies
1468 or assortments according to their items, which are parsed from the current
1471 Is called whenever the qty of an item is changed or items are deleted.
1475 * part_type : 'assortment' or 'assembly' (mandatory)
1477 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1479 Depending on the price_type the lastcost sum or sellprice sum is returned.
1481 Doesn't work for recursive items.
1485 =head1 GET SET INITS
1487 There are get_set_inits for
1495 which parse $::form and automatically create an array of objects.
1497 These inits are used during saving and each time a new element is added.
1501 =item C<init_makemodels>
1503 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1504 $self->part->makemodels, ready to be saved.
1506 Used for saving parts and adding new makemodel rows.
1508 =item C<parse_add_items_to_objects PART_TYPE>
1510 Parses the resulting form from either the part-picker submit or the multi-item
1511 submit, and creates an arrayref of assortment_item or assembly objects, that
1512 can be rendered via C<render_assortment_items_to_html> or
1513 C<render_assembly_items_to_html>.
1515 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1516 Optional param: position (used for numbering and listrow class)
1518 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1520 Takes an array_ref of assortment_items, and generates tables rows ready for
1521 adding to the assortment table. Is used when a part is loaded, or whenever new
1522 assortment items are added.
1524 =item C<parse_form_makemodels>
1526 Makemodels can't just be overwritten, because of the field "lastupdate", that
1527 remembers when the lastcost for that vendor changed the last time.
1529 So the original values are cloned and remembered, so we can compare if lastcost
1530 was changed in $::form, and keep or update lastupdate.
1532 lastcost isn't updated until the first time it was saved with a value, until
1535 Also a boolean "makemodel" needs to be written in parts, depending on whether
1536 makemodel entries exist or not.
1538 We still need init_makemodels for when we open the part for editing.
1548 It should be possible to jump to the edit page in a specific tab
1552 Support callbacks, e.g. creating a new part from within an order, and jumping
1553 back to the order again afterwards.
1557 Support units when adding assembly items or assortment items. Currently the
1558 default unit of the item is always used.
1562 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1563 consists of other assemblies.
1569 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>