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;
97 if ( $is_new and !$::form->{part}{partnumber} ) {
98 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
103 my @errors = $self->part->validate;
104 return $self->js->error(@errors)->render if @errors;
106 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
107 $self->part->db->with_transaction(sub {
109 if ( $params{save_as_new} ) {
110 $self->part( $self->part->clone_and_reset_deep );
111 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
114 $self->part->save(cascade => 1);
116 SL::DB::History->new(
117 trans_id => $self->part->id,
118 snumbers => 'partnumber_' . $self->part->partnumber,
119 employee_id => SL::DB::Manager::Employee->current->id,
124 CVar->save_custom_variables(
125 dbh => $self->part->db->dbh,
127 trans_id => $self->part->id,
128 variables => $::form, # $::form->{cvar} would be nicer
133 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
135 flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
137 if ( $::form->{callback} ) {
138 $self->redirect_to($::form->unescape($::form->{callback}));
140 # default behaviour after save: reload item, this also resets last_modification!
141 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
145 sub action_save_as_new {
147 $self->action_save(save_as_new=>1);
153 my $db = $self->part->db; # $self->part has a get_set_init on $::form
155 my $partnumber = $self->part->partnumber; # remember for history log
160 # delete part, together with relationships that don't already
161 # have an ON DELETE CASCADE, e.g. makemodel and translation.
162 $self->part->delete(cascade => 1);
164 SL::DB::History->new(
165 trans_id => $self->part->id,
166 snumbers => 'partnumber_' . $partnumber,
167 employee_id => SL::DB::Manager::Employee->current->id,
169 addition => 'DELETED',
172 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
174 flash_later('info', t8('The item has been deleted.'));
175 if ( $::form->{callback} ) {
176 $self->redirect_to($::form->unescape($::form->{callback}));
178 my @redirect_params = (
179 controller => 'controller.pl',
180 action => 'LoginScreen/user_login'
182 $self->redirect_to(@redirect_params);
186 sub action_use_as_new {
187 my ($self, %params) = @_;
189 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
190 $::form->{oldpartnumber} = $oldpart->partnumber;
192 $self->part($oldpart->clone_and_reset_deep);
194 $self->part->partnumber(undef);
200 my ($self, %params) = @_;
206 my ($self, %params) = @_;
208 $self->_set_javascript;
209 $self->_setup_form_action_bar;
211 my (%assortment_vars, %assembly_vars);
212 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
213 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
215 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
217 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
218 if (scalar @{ $params{CUSTOM_VARIABLES} });
220 my %title_hash = ( part => t8('Edit Part'),
221 assembly => t8('Edit Assembly'),
222 service => t8('Edit Service'),
223 assortment => t8('Edit Assortment'),
226 $self->part->prices([]) unless $self->part->prices;
227 $self->part->translations([]) unless $self->part->translations;
231 title => $title_hash{$self->part->part_type},
234 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
235 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
236 oldpartnumber => $::form->{oldpartnumber},
237 old_id => $::form->{old_id},
245 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
246 $_[0]->render('part/history', { layout => 0 },
247 history_entries => $history_entries);
250 sub action_update_item_totals {
253 my $part_type = $::form->{part_type};
254 die unless $part_type =~ /^(assortment|assembly)$/;
256 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
257 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
259 my $sum_diff = $sellprice_sum-$lastcost_sum;
262 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
263 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
264 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
265 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
266 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
267 ->no_flash_clear->render();
270 sub action_add_multi_assortment_items {
273 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
274 my $html = $self->render_assortment_items_to_html($item_objects);
276 $self->js->run('kivi.Part.close_picker_dialogs')
277 ->append('#assortment_rows', $html)
278 ->run('kivi.Part.renumber_positions')
279 ->run('kivi.Part.assortment_recalc')
283 sub action_add_multi_assembly_items {
286 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
288 foreach my $item (@{$item_objects}) {
289 my $errstr = validate_assembly($item->part,$self->part);
290 $self->js->flash('error',$errstr) if $errstr;
291 push (@checked_objects,$item) unless $errstr;
294 my $html = $self->render_assembly_items_to_html(\@checked_objects);
296 $self->js->run('kivi.Part.close_picker_dialogs')
297 ->append('#assembly_rows', $html)
298 ->run('kivi.Part.renumber_positions')
299 ->run('kivi.Part.assembly_recalc')
303 sub action_add_assortment_item {
304 my ($self, %params) = @_;
306 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
308 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
310 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
311 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
312 return $self->js->flash('error', t8("This part has already been added."))->render;
315 my $number_of_items = scalar @{$self->assortment_items};
316 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
317 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
319 push(@{$self->assortment_items}, @{$item_objects});
320 my $part = SL::DB::Part->new(part_type => 'assortment');
321 $part->assortment_items(@{$self->assortment_items});
322 my $items_sellprice_sum = $part->items_sellprice_sum;
323 my $items_lastcost_sum = $part->items_lastcost_sum;
324 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
327 ->append('#assortment_rows' , $html) # append in tbody
328 ->val('.add_assortment_item_input' , '')
329 ->run('kivi.Part.focus_last_assortment_input')
330 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
331 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
332 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
333 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
334 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
338 sub action_add_assembly_item {
341 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
343 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
345 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
347 my $duplicate_warning = 0; # duplicates are allowed, just warn
348 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
349 $duplicate_warning++;
352 my $number_of_items = scalar @{$self->assembly_items};
353 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
355 foreach my $item (@{$item_objects}) {
356 my $errstr = validate_assembly($item->part,$self->part);
357 return $self->js->flash('error',$errstr)->render if $errstr;
362 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
364 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
366 push(@{$self->assembly_items}, @{$item_objects});
367 my $part = SL::DB::Part->new(part_type => 'assembly');
368 $part->assemblies(@{$self->assembly_items});
369 my $items_sellprice_sum = $part->items_sellprice_sum;
370 my $items_lastcost_sum = $part->items_lastcost_sum;
371 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
374 ->append('#assembly_rows', $html) # append in tbody
375 ->val('.add_assembly_item_input' , '')
376 ->run('kivi.Part.focus_last_assembly_input')
377 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
378 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
379 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
380 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
381 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
385 sub action_show_multi_items_dialog {
386 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
387 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
391 sub action_multi_items_update_result {
394 $::form->{multi_items}->{filter}->{obsolete} = 0;
396 my $count = $_[0]->multi_items_models->count;
399 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
400 $_[0]->render($text, { layout => 0 });
401 } elsif ($count > $max_count) {
402 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
403 $_[0]->render($text, { layout => 0 });
405 my $multi_items = $_[0]->multi_items_models->get;
406 $_[0]->render('part/_multi_items_result', { layout => 0 },
407 multi_items => $multi_items);
411 sub action_add_makemodel_row {
414 my $vendor_id = $::form->{add_makemodel};
416 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
417 return $self->js->error(t8("No vendor selected or found!"))->render;
419 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
420 $self->js->flash('info', t8("This vendor has already been added."));
423 my $position = scalar @{$self->makemodels} + 1;
425 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
429 sortorder => $position,
430 ) or die "Can't create MakeModel object";
432 my $row_as_html = $self->p->render('part/_makemodel_row',
434 listrow => $position % 2 ? 0 : 1,
437 # after selection focus on the model field in the row that was just added
439 ->append('#makemodel_rows', $row_as_html) # append in tbody
440 ->val('.add_makemodel_input', '')
441 ->run('kivi.Part.focus_last_makemodel_input')
445 sub action_reorder_items {
448 my $part_type = $::form->{part_type};
451 partnumber => sub { $_[0]->part->partnumber },
452 description => sub { $_[0]->part->description },
453 qty => sub { $_[0]->qty },
454 sellprice => sub { $_[0]->part->sellprice },
455 lastcost => sub { $_[0]->part->lastcost },
456 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
459 my $method = $sort_keys{$::form->{order_by}};
462 if ($part_type eq 'assortment') {
463 @items = @{ $self->assortment_items };
465 @items = @{ $self->assembly_items };
468 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
469 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
470 if ($::form->{sort_dir}) {
471 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
473 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
476 if ($::form->{sort_dir}) {
477 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
479 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
483 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
486 sub action_warehouse_changed {
489 if ($::form->{warehouse_id} ) {
490 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
491 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
493 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
494 $self->bin($self->warehouse->bins->[0]);
496 ->html('#bin', $self->build_bin_select)
497 ->focus('#part_bin_id');
498 return $self->js->render;
502 # no warehouse was selected, empty the bin field and reset the id
504 ->val('#part_bin_id', undef)
507 return $self->js->render;
510 sub action_ajax_autocomplete {
511 my ($self, %params) = @_;
513 # if someone types something, and hits enter, assume he entered the full name.
514 # if something matches, treat that as sole match
515 # since we need a second get models instance with different filters for that,
516 # we only modify the original filter temporarily in place
517 if ($::form->{prefer_exact}) {
518 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
520 my $exact_models = SL::Controller::Helper::GetModels->new(
523 paginated => { per_page => 2 },
524 with_objects => [ qw(unit_obj classification) ],
527 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
528 $self->parts($exact_matches);
534 value => $_->displayable_name,
535 label => $_->displayable_name,
537 partnumber => $_->partnumber,
538 description => $_->description,
539 part_type => $_->part_type,
541 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
543 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
545 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
548 sub action_test_page {
549 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
552 sub action_part_picker_search {
553 $_[0]->render('part/part_picker_search', { layout => 0 });
556 sub action_part_picker_result {
557 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
563 if ($::request->type eq 'json') {
568 $part_hash = $self->part->as_tree;
569 $part_hash->{cvars} = $self->part->cvar_as_hashref;
572 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
577 sub validate_add_items {
578 scalar @{$::form->{add_items}};
581 sub prepare_assortment_render_vars {
584 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
585 items_lastcost_sum => $self->part->items_lastcost_sum,
586 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
588 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
593 sub prepare_assembly_render_vars {
596 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
597 items_lastcost_sum => $self->part->items_lastcost_sum,
598 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
600 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
608 check_has_valid_part_type($self->part->part_type);
610 $self->_set_javascript;
611 $self->_setup_form_action_bar;
613 my %title_hash = ( part => t8('Add Part'),
614 assembly => t8('Add Assembly'),
615 service => t8('Add Service'),
616 assortment => t8('Add Assortment'),
621 title => $title_hash{$self->part->part_type},
626 sub _set_javascript {
628 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
629 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
632 sub recalc_item_totals {
633 my ($self, %params) = @_;
635 if ( $params{part_type} eq 'assortment' ) {
636 return 0 unless scalar @{$self->assortment_items};
637 } elsif ( $params{part_type} eq 'assembly' ) {
638 return 0 unless scalar @{$self->assembly_items};
640 carp "can only calculate sum for assortments and assemblies";
643 my $part = SL::DB::Part->new(part_type => $params{part_type});
644 if ( $part->is_assortment ) {
645 $part->assortment_items( @{$self->assortment_items} );
646 if ( $params{price_type} eq 'lastcost' ) {
647 return $part->items_lastcost_sum;
649 if ( $params{pricegroup_id} ) {
650 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
652 return $part->items_sellprice_sum;
655 } elsif ( $part->is_assembly ) {
656 $part->assemblies( @{$self->assembly_items} );
657 if ( $params{price_type} eq 'lastcost' ) {
658 return $part->items_lastcost_sum;
660 return $part->items_sellprice_sum;
665 sub check_part_not_modified {
668 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
675 my $is_new = !$self->part->id;
677 my $params = delete($::form->{part}) || { };
679 delete $params->{id};
680 # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
681 delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
682 $self->part->assign_attributes(%{ $params});
683 $self->part->bin_id(undef) unless $self->part->warehouse_id;
685 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
686 # will be the case for used assortments when saving, or when a used assortment
688 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
689 $self->part->assortment_items([]);
690 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
693 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
694 $self->part->assemblies([]); # completely rewrite assortments each time
695 $self->part->add_assemblies( @{ $self->assembly_items } );
698 $self->part->translations([]);
699 $self->parse_form_translations;
701 $self->part->prices([]);
702 $self->parse_form_prices;
704 $self->parse_form_makemodels;
707 sub parse_form_prices {
709 # only save prices > 0
710 my $prices = delete($::form->{prices}) || [];
711 foreach my $price ( @{$prices} ) {
712 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
713 next unless $sellprice > 0; # skip negative prices as well
714 my $p = SL::DB::Price->new(parts_id => $self->part->id,
715 pricegroup_id => $price->{pricegroup_id},
718 $self->part->add_prices($p);
722 sub parse_form_translations {
724 # don't add empty translations
725 my $translations = delete($::form->{translations}) || [];
726 foreach my $translation ( @{$translations} ) {
727 next unless $translation->{translation};
728 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
729 $self->part->add_translations( $translation );
733 sub parse_form_makemodels {
737 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
738 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
741 $self->part->makemodels([]);
744 my $makemodels = delete($::form->{makemodels}) || [];
745 foreach my $makemodel ( @{$makemodels} ) {
746 next unless $makemodel->{make};
748 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
750 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
751 id => $makemodel->{id},
752 make => $makemodel->{make},
753 model => $makemodel->{model} || '',
754 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
755 sortorder => $position,
757 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
758 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
759 # don't change lastupdate
760 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
761 # new makemodel, no lastcost entered, leave lastupdate empty
762 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
763 # lastcost hasn't changed, use original lastupdate
764 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
766 $mm->lastupdate(DateTime->now);
768 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
769 $self->part->add_makemodels($mm);
773 sub build_bin_select {
774 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
775 title_key => 'description',
776 default => $_[0]->bin->id,
780 # get_set_inits for partpicker
783 if ($::form->{no_paginate}) {
784 $_[0]->models->disable_plugin('paginated');
790 # get_set_inits for part controller
794 # used by edit, save, delete and add
796 if ( $::form->{part}{id} ) {
797 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
799 die "part_type missing" unless $::form->{part}{part_type};
800 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
806 return $self->part->orphaned;
812 SL::Controller::Helper::GetModels->new(
819 partnumber => t8('Partnumber'),
820 description => t8('Description'),
822 with_objects => [ qw(unit_obj classification) ],
831 sub init_assortment_items {
832 # this init is used while saving and whenever assortments change dynamically
836 my $assortment_items = delete($::form->{assortment_items}) || [];
837 foreach my $assortment_item ( @{$assortment_items} ) {
838 next unless $assortment_item->{parts_id};
840 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
841 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
842 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
843 charge => $assortment_item->{charge},
844 unit => $assortment_item->{unit} || $part->unit,
845 position => $position,
853 sub init_makemodels {
857 my @makemodel_array = ();
858 my $makemodels = delete($::form->{makemodels}) || [];
860 foreach my $makemodel ( @{$makemodels} ) {
861 next unless $makemodel->{make};
863 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
864 id => $makemodel->{id},
865 make => $makemodel->{make},
866 model => $makemodel->{model} || '',
867 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
868 sortorder => $position,
869 ) or die "Can't create mm";
870 # $mm->id($makemodel->{id}) if $makemodel->{id};
871 push(@makemodel_array, $mm);
873 return \@makemodel_array;
876 sub init_assembly_items {
880 my $assembly_items = delete($::form->{assembly_items}) || [];
881 foreach my $assembly_item ( @{$assembly_items} ) {
882 next unless $assembly_item->{parts_id};
884 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
885 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
886 bom => $assembly_item->{bom},
887 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
888 position => $position,
895 sub init_all_warehouses {
897 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
900 sub init_all_languages {
901 SL::DB::Manager::Language->get_all_sorted;
904 sub init_all_partsgroups {
906 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
909 sub init_all_buchungsgruppen {
911 if ( $self->part->orphaned ) {
912 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
914 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
920 if ( $self->part->orphaned ) {
921 return SL::DB::Manager::Unit->get_all_sorted;
923 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
927 sub init_all_payment_terms {
929 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
932 sub init_all_price_factors {
933 SL::DB::Manager::PriceFactor->get_all_sorted;
936 sub init_all_pricegroups {
937 SL::DB::Manager::Pricegroup->get_all_sorted;
940 # model used to filter/display the parts in the multi-items dialog
941 sub init_multi_items_models {
942 SL::Controller::Helper::GetModels->new(
945 with_objects => [ qw(unit_obj partsgroup classification) ],
946 disable_plugin => 'paginated',
947 source => $::form->{multi_items},
953 partnumber => t8('Partnumber'),
954 description => t8('Description')}
958 # simple checks to run on $::form before saving
960 sub form_check_part_description_exists {
963 return 1 if $::form->{part}{description};
965 $self->js->flash('error', t8('Part Description missing!'))
966 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
967 ->focus('#part_description');
971 sub form_check_assortment_items_exist {
974 return 1 unless $::form->{part}{part_type} eq 'assortment';
975 # skip item check for existing assortments that have been used
976 return 1 if ($self->part->id and !$self->part->orphaned);
978 # new or orphaned parts must have items in $::form->{assortment_items}
979 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
980 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
981 ->focus('#add_assortment_item_name')
982 ->flash('error', t8('The assortment doesn\'t have any items.'));
988 sub form_check_assortment_items_unique {
991 return 1 unless $::form->{part}{part_type} eq 'assortment';
993 my %duplicate_elements;
995 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
996 $duplicate_elements{$_}++ if $count{$_}++;
999 if ( keys %duplicate_elements ) {
1000 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1001 ->flash('error', t8('There are duplicate assortment items'));
1007 sub form_check_assembly_items_exist {
1010 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1012 # skip item check for existing assembly that have been used
1013 return 1 if ($self->part->id and !$self->part->orphaned);
1015 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1016 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1017 ->focus('#add_assembly_item_name')
1018 ->flash('error', t8('The assembly doesn\'t have any items.'));
1024 sub form_check_partnumber_is_unique {
1027 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1028 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1030 $self->js->flash('error', t8('The partnumber already exists!'))
1031 ->focus('#part_description');
1038 # general checking functions
1039 sub check_next_transnumber_is_free {
1042 my ($next_transnumber, $count);
1043 $self->part->db->with_transaction(sub {
1044 $next_transnumber = $self->part->get_next_trans_number;
1045 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1048 $count ? return 0 : return 1;
1052 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1058 $self->form_check_part_description_exists || return 0;
1059 $self->form_check_assortment_items_exist || return 0;
1060 $self->form_check_assortment_items_unique || return 0;
1061 $self->form_check_assembly_items_exist || return 0;
1062 $self->form_check_partnumber_is_unique || return 0;
1067 sub check_has_valid_part_type {
1068 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1071 sub render_assortment_items_to_html {
1072 my ($self, $assortment_items, $number_of_items) = @_;
1074 my $position = $number_of_items + 1;
1076 foreach my $ai (@$assortment_items) {
1077 $html .= $self->p->render('part/_assortment_row',
1078 PART => $self->part,
1079 orphaned => $self->orphaned,
1081 listrow => $position % 2 ? 1 : 0,
1082 position => $position, # for legacy assemblies
1089 sub render_assembly_items_to_html {
1090 my ($self, $assembly_items, $number_of_items) = @_;
1092 my $position = $number_of_items + 1;
1094 foreach my $ai (@{$assembly_items}) {
1095 $html .= $self->p->render('part/_assembly_row',
1096 PART => $self->part,
1097 orphaned => $self->orphaned,
1099 listrow => $position % 2 ? 1 : 0,
1100 position => $position, # for legacy assemblies
1107 sub parse_add_items_to_objects {
1108 my ($self, %params) = @_;
1109 my $part_type = $params{part_type};
1110 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1111 my $position = $params{position} || 1;
1113 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1116 foreach my $item ( @add_items ) {
1117 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1119 if ( $part_type eq 'assortment' ) {
1120 $ai = SL::DB::AssortmentItem->new(part => $part,
1121 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1122 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1123 position => $position,
1124 ) or die "Can't create AssortmentItem from item";
1125 } elsif ( $part_type eq 'assembly' ) {
1126 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1127 # id => $self->assembly->id, # will be set on save
1128 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1129 bom => 0, # default when adding: no bom
1130 position => $position,
1133 die "part_type must be assortment or assembly";
1135 push(@item_objects, $ai);
1139 return \@item_objects;
1142 sub _setup_form_action_bar {
1145 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1147 for my $bar ($::request->layout->get('actionbar')) {
1152 call => [ 'kivi.Part.save' ],
1153 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1154 accesskey => 'enter',
1158 call => [ 'kivi.Part.use_as_new' ],
1159 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1160 : !$may_edit ? t8('You do not have the permissions to access this function.')
1163 ], # end of combobox "Save"
1167 call => [ 'kivi.Part.delete' ],
1168 confirm => t8('Do you really want to delete this object?'),
1169 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1170 : !$may_edit ? t8('You do not have the permissions to access this function.')
1171 : !$self->part->orphaned ? t8('This object has already been used.')
1179 call => [ 'kivi.Part.open_history_popup' ],
1180 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1181 : !$may_edit ? t8('You do not have the permissions to access this function.')
1196 SL::Controller::Part - Part CRUD controller
1200 Controller for adding/editing/saving/deleting parts.
1202 All the relations are loaded at once and saving the part, adding a history
1203 entry and saving CVars happens inside one transaction. When saving the old
1204 relations are deleted and written as new to the database.
1206 Relations for parts:
1214 =item assembly items
1216 =item assortment items
1224 There are 4 different part types:
1230 The "default" part type.
1232 inventory_accno_id is set.
1236 Services can't be stocked.
1238 inventory_accno_id isn't set.
1242 Assemblies consist of other parts, services, assemblies or assortments. They
1243 aren't meant to be bought, only sold. To add assemblies to stock you typically
1244 have to make them, which reduces the stock by its respective components. Once
1245 an assembly item has been created there is currently no way to "disassemble" it
1246 again. An assembly item can appear several times in one assembly. An assmbly is
1247 sold as one item with a defined sellprice and lastcost. If the component prices
1248 change the assortment price remains the same. The assembly items may be printed
1249 in a record if the item's "bom" is set.
1253 Similar to assembly, but each assortment item may only appear once per
1254 assortment. When selling an assortment the assortment items are added to the
1255 record together with the assortment, which is added with sellprice 0.
1257 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1258 determined by the sum of the current assortment item prices when the assortment
1259 is added to a record. This also means that price rules and customer discounts
1260 will be applied to the assortment items.
1262 Once the assortment items have been added they may be modified or deleted, just
1263 as if they had been added manually, the individual assortment items aren't
1264 linked to the assortment or the other assortment items in any way.
1272 =item C<action_add_part>
1274 =item C<action_add_service>
1276 =item C<action_add_assembly>
1278 =item C<action_add_assortment>
1280 =item C<action_add PART_TYPE>
1282 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1283 parameter part_type as an action. Example:
1285 controller.pl?action=Part/add&part_type=service
1287 =item C<action_save>
1289 Saves the current part and then reloads the edit page for the part.
1291 =item C<action_use_as_new>
1293 Takes the information from the current part, plus any modifications made on the
1294 page, and creates a new edit page that is ready to be saved. The partnumber is
1295 set empty, so a new partnumber from the number range will be used if the user
1296 doesn't enter one manually.
1298 Unsaved changes to the original part aren't updated.
1300 The part type cannot be changed in this way.
1302 =item C<action_delete>
1304 Deletes the current part and then redirects to the main page, there is no
1307 The delete button only appears if the part is 'orphaned', according to
1308 SL::DB::Part orphaned.
1310 The part can't be deleted if it appears in invoices, orders, delivery orders,
1311 the inventory, or is part of an assembly or assortment.
1313 If the part is deleted its relations prices, makdemodel, assembly,
1314 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1316 Before this controller items that appeared in inventory didn't count as
1317 orphaned and could be deleted and the inventory entries were also deleted, this
1318 "feature" hasn't been implemented.
1320 =item C<action_edit part.id>
1322 Load and display a part for editing.
1324 controller.pl?action=Part/edit&part.id=12345
1326 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1330 =head1 BUTTON ACTIONS
1336 Opens a popup displaying all the history entries. Once a new history controller
1337 is written the button could link there instead, with the part already selected.
1345 =item C<action_update_item_totals>
1347 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1348 amount of an item changes. The sum of all sellprices and lastcosts is
1349 calculated and the totals updated. Uses C<recalc_item_totals>.
1351 =item C<action_add_assortment_item>
1353 Adds a new assortment item from a part picker seleciton to the assortment item list
1355 If the item already exists in the assortment the item isn't added and a Flash
1358 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1359 after adding each new item, add the new object to the item objects that were
1360 already parsed, calculate totals via a dummy part then update the row and the
1363 =item C<action_add_assembly_item>
1365 Adds a new assembly item from a part picker seleciton to the assembly item list
1367 If the item already exists in the assembly a flash info is generated, but the
1370 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1371 after adding each new item, add the new object to the item objects that were
1372 already parsed, calculate totals via a dummy part then update the row and the
1375 =item C<action_add_multi_assortment_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 assortment item table. Afterwards all
1379 assortment items are renumbered and the sums recalculated via
1380 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1382 =item C<action_add_multi_assembly_items>
1384 Parses the items to be added from the form generated by the multi input and
1385 appends the html of the tr-rows to the assembly item table. Afterwards all
1386 assembly items are renumbered and the sums recalculated via
1387 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1389 =item C<action_show_multi_items_dialog>
1391 =item C<action_multi_items_update_result>
1393 =item C<action_add_makemodel_row>
1395 Add a new makemodel row with the vendor that was selected via the vendor
1398 Checks the already existing makemodels and warns if a row with that vendor
1399 already exists. Currently it is possible to have duplicate vendor rows.
1401 =item C<action_reorder_items>
1403 Sorts the item table for assembly or assortment items.
1405 =item C<action_warehouse_changed>
1409 =head1 ACTIONS part picker
1413 =item C<action_ajax_autocomplete>
1415 =item C<action_test_page>
1417 =item C<action_part_picker_search>
1419 =item C<action_part_picker_result>
1421 =item C<action_show>
1431 Calls some simple checks that test the submitted $::form for obvious errors.
1432 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1434 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1435 some cases extra actions are taken, e.g. if the part description is missing the
1436 basic data tab is selected and the description input field is focussed.
1442 =item C<form_check_part_description_exists>
1444 =item C<form_check_assortment_items_exist>
1446 =item C<form_check_assortment_items_unique>
1448 =item C<form_check_assembly_items_exist>
1450 =item C<form_check_partnumber_is_unique>
1454 =head1 HELPER FUNCTIONS
1460 When submitting the form for saving, parses the transmitted form. Expects the
1464 $::form->{makemodels}
1465 $::form->{translations}
1467 $::form->{assemblies}
1468 $::form->{assortments}
1470 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1472 =item C<recalc_item_totals %params>
1474 Helper function for calculating the total lastcost and sellprice for assemblies
1475 or assortments according to their items, which are parsed from the current
1478 Is called whenever the qty of an item is changed or items are deleted.
1482 * part_type : 'assortment' or 'assembly' (mandatory)
1484 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1486 Depending on the price_type the lastcost sum or sellprice sum is returned.
1488 Doesn't work for recursive items.
1492 =head1 GET SET INITS
1494 There are get_set_inits for
1502 which parse $::form and automatically create an array of objects.
1504 These inits are used during saving and each time a new element is added.
1508 =item C<init_makemodels>
1510 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1511 $self->part->makemodels, ready to be saved.
1513 Used for saving parts and adding new makemodel rows.
1515 =item C<parse_add_items_to_objects PART_TYPE>
1517 Parses the resulting form from either the part-picker submit or the multi-item
1518 submit, and creates an arrayref of assortment_item or assembly objects, that
1519 can be rendered via C<render_assortment_items_to_html> or
1520 C<render_assembly_items_to_html>.
1522 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1523 Optional param: position (used for numbering and listrow class)
1525 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1527 Takes an array_ref of assortment_items, and generates tables rows ready for
1528 adding to the assortment table. Is used when a part is loaded, or whenever new
1529 assortment items are added.
1531 =item C<parse_form_makemodels>
1533 Makemodels can't just be overwritten, because of the field "lastupdate", that
1534 remembers when the lastcost for that vendor changed the last time.
1536 So the original values are cloned and remembered, so we can compare if lastcost
1537 was changed in $::form, and keep or update lastupdate.
1539 lastcost isn't updated until the first time it was saved with a value, until
1542 Also a boolean "makemodel" needs to be written in parts, depending on whether
1543 makemodel entries exist or not.
1545 We still need init_makemodels for when we open the part for editing.
1555 It should be possible to jump to the edit page in a specific tab
1559 Support callbacks, e.g. creating a new part from within an order, and jumping
1560 back to the order again afterwards.
1564 Support units when adding assembly items or assortment items. Currently the
1565 default unit of the item is always used.
1569 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1570 consists of other assemblies.
1576 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>