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