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 {
898 SL::DB::Manager::PaymentTerm->get_all_sorted;
901 sub init_all_price_factors {
902 SL::DB::Manager::PriceFactor->get_all_sorted;
905 sub init_all_pricegroups {
906 SL::DB::Manager::Pricegroup->get_all_sorted;
909 # model used to filter/display the parts in the multi-items dialog
910 sub init_multi_items_models {
911 SL::Controller::Helper::GetModels->new(
914 with_objects => [ qw(unit_obj partsgroup) ],
915 disable_plugin => 'paginated',
916 source => $::form->{multi_items},
922 partnumber => t8('Partnumber'),
923 description => t8('Description')}
927 # simple checks to run on $::form before saving
929 sub form_check_part_description_exists {
932 return 1 if $::form->{part}{description};
934 $self->js->flash('error', t8('Part Description missing!'))
935 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
936 ->focus('#part_description');
940 sub form_check_assortment_items_exist {
943 return 1 unless $::form->{part}{part_type} eq 'assortment';
944 # skip check for existing parts that have been used
945 return 1 if ($self->part->id and !$self->part->orphaned);
947 # new or orphaned parts must have items in $::form->{assortment_items}
948 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
949 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
950 ->focus('#add_assortment_item_name')
951 ->flash('error', t8('The assortment doesn\'t have any items.'));
957 sub form_check_assortment_items_unique {
960 return 1 unless $::form->{part}{part_type} eq 'assortment';
962 my %duplicate_elements;
964 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
965 $duplicate_elements{$_}++ if $count{$_}++;
968 if ( keys %duplicate_elements ) {
969 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
970 ->flash('error', t8('There are duplicate assortment items'));
976 sub form_check_assembly_items_exist {
979 return 1 unless $::form->{part}->{part_type} eq 'assembly';
981 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
982 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
983 ->focus('#add_assembly_item_name')
984 ->flash('error', t8('The assembly doesn\'t have any items.'));
990 sub form_check_partnumber_is_unique {
993 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
994 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
996 $self->js->flash('error', t8('The partnumber already exists!'))
997 ->focus('#part_description');
1004 # general checking functions
1005 sub check_next_transnumber_is_free {
1008 my ($next_transnumber, $count);
1009 $self->part->db->with_transaction(sub {
1010 $next_transnumber = $self->part->get_next_trans_number;
1011 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1014 $count ? return 0 : return 1;
1018 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1024 $self->form_check_part_description_exists || return 0;
1025 $self->form_check_assortment_items_exist || return 0;
1026 $self->form_check_assortment_items_unique || return 0;
1027 $self->form_check_assembly_items_exist || return 0;
1028 $self->form_check_partnumber_is_unique || return 0;
1033 sub check_has_valid_part_type {
1034 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1037 sub render_assortment_items_to_html {
1038 my ($self, $assortment_items, $number_of_items) = @_;
1040 my $position = $number_of_items + 1;
1042 foreach my $ai (@$assortment_items) {
1043 $html .= $self->p->render('part/_assortment_row',
1044 PART => $self->part,
1045 orphaned => $self->orphaned,
1047 listrow => $position % 2 ? 1 : 0,
1048 position => $position, # for legacy assemblies
1055 sub render_assembly_items_to_html {
1056 my ($self, $assembly_items, $number_of_items) = @_;
1058 my $position = $number_of_items + 1;
1060 foreach my $ai (@{$assembly_items}) {
1061 $html .= $self->p->render('part/_assembly_row',
1062 PART => $self->part,
1063 orphaned => $self->orphaned,
1065 listrow => $position % 2 ? 1 : 0,
1066 position => $position, # for legacy assemblies
1073 sub parse_add_items_to_objects {
1074 my ($self, %params) = @_;
1075 my $part_type = $params{part_type};
1076 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1077 my $position = $params{position} || 1;
1079 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1082 foreach my $item ( @add_items ) {
1083 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1085 if ( $part_type eq 'assortment' ) {
1086 $ai = SL::DB::AssortmentItem->new(part => $part,
1087 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1088 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1089 position => $position,
1090 ) or die "Can't create AssortmentItem from item";
1091 } elsif ( $part_type eq 'assembly' ) {
1092 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1093 # id => $self->assembly->id, # will be set on save
1094 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1095 bom => 0, # default when adding: no bom
1096 position => $position,
1099 die "part_type must be assortment or assembly";
1101 push(@item_objects, $ai);
1105 return \@item_objects;
1116 SL::Controller::Part - Part CRUD controller
1120 Controller for adding/editing/saving/deleting parts.
1122 All the relations are loaded at once and saving the part, adding a history
1123 entry and saving CVars happens inside one transaction. When saving the old
1124 relations are deleted and written as new to the database.
1126 Relations for parts:
1134 =item assembly items
1136 =item assortment items
1144 There are 4 different part types:
1150 The "default" part type.
1152 inventory_accno_id is set.
1156 Services can't be stocked.
1158 inventory_accno_id isn't set.
1162 Assemblies consist of other parts, services, assemblies or assortments. They
1163 aren't meant to be bought, only sold. To add assemblies to stock you typically
1164 have to make them, which reduces the stock by its respective components. Once
1165 an assembly item has been created there is currently no way to "disassemble" it
1166 again. An assembly item can appear several times in one assembly. An assmbly is
1167 sold as one item with a defined sellprice and lastcost. If the component prices
1168 change the assortment price remains the same. The assembly items may be printed
1169 in a record if the item's "bom" is set.
1173 Similar to assembly, but each assortment item may only appear once per
1174 assortment. When selling an assortment the assortment items are added to the
1175 record together with the assortment, which is added with sellprice 0.
1177 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1178 determined by the sum of the current assortment item prices when the assortment
1179 is added to a record. This also means that price rules and customer discounts
1180 will be applied to the assortment items.
1182 Once the assortment items have been added they may be modified or deleted, just
1183 as if they had been added manually, the individual assortment items aren't
1184 linked to the assortment or the other assortment items in any way.
1192 =item C<action_add_part>
1194 =item C<action_add_service>
1196 =item C<action_add_assembly>
1198 =item C<action_add_assortment>
1200 =item C<action_add PART_TYPE>
1202 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1203 parameter part_type as an action. Example:
1205 controller.pl?action=Part/add&part_type=service
1207 =item C<action_save>
1209 Saves the current part and then reloads the edit page for the part.
1211 =item C<action_use_as_new>
1213 Takes the information from the current part, plus any modifications made on the
1214 page, and creates a new edit page that is ready to be saved. The partnumber is
1215 set empty, so a new partnumber from the number range will be used if the user
1216 doesn't enter one manually.
1218 Unsaved changes to the original part aren't updated.
1220 The part type cannot be changed in this way.
1222 =item C<action_delete>
1224 Deletes the current part and then redirects to the main page, there is no
1227 The delete button only appears if the part is 'orphaned', according to
1228 SL::DB::Part orphaned.
1230 The part can't be deleted if it appears in invoices, orders, delivery orders,
1231 the inventory, or is part of an assembly or assortment.
1233 If the part is deleted its relations prices, makdemodel, assembly,
1234 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1236 Before this controller items that appeared in inventory didn't count as
1237 orphaned and could be deleted and the inventory entries were also deleted, this
1238 "feature" hasn't been implemented.
1240 =item C<action_edit part.id>
1242 Load and display a part for editing.
1244 controller.pl?action=Part/edit&part.id=12345
1246 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1250 =head1 BUTTON ACTIONS
1256 Opens a popup displaying all the history entries. Once a new history controller
1257 is written the button could link there instead, with the part already selected.
1265 =item C<action_update_item_totals>
1267 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1268 amount of an item changes. The sum of all sellprices and lastcosts is
1269 calculated and the totals updated. Uses C<recalc_item_totals>.
1271 =item C<action_add_assortment_item>
1273 Adds a new assortment item from a part picker seleciton to the assortment item list
1275 If the item already exists in the assortment the item isn't added and a Flash
1278 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1279 after adding each new item, add the new object to the item objects that were
1280 already parsed, calculate totals via a dummy part then update the row and the
1283 =item C<action_add_assembly_item>
1285 Adds a new assembly item from a part picker seleciton to the assembly item list
1287 If the item already exists in the assembly a flash info is generated, but the
1290 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1291 after adding each new item, add the new object to the item objects that were
1292 already parsed, calculate totals via a dummy part then update the row and the
1295 =item C<action_add_multi_assortment_items>
1297 Parses the items to be added from the form generated by the multi input and
1298 appends the html of the tr-rows to the assortment item table. Afterwards all
1299 assortment items are renumbered and the sums recalculated via
1300 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1302 =item C<action_add_multi_assembly_items>
1304 Parses the items to be added from the form generated by the multi input and
1305 appends the html of the tr-rows to the assembly item table. Afterwards all
1306 assembly items are renumbered and the sums recalculated via
1307 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1309 =item C<action_show_multi_items_dialog>
1311 =item C<action_multi_items_update_result>
1313 =item C<action_add_makemodel_row>
1315 Add a new makemodel row with the vendor that was selected via the vendor
1318 Checks the already existing makemodels and warns if a row with that vendor
1319 already exists. Currently it is possible to have duplicate vendor rows.
1321 =item C<action_reorder_items>
1323 Sorts the item table for assembly or assortment items.
1325 =item C<action_warehouse_changed>
1329 =head1 ACTIONS part picker
1333 =item C<action_ajax_autocomplete>
1335 =item C<action_test_page>
1337 =item C<action_part_picker_search>
1339 =item C<action_part_picker_result>
1341 =item C<action_show>
1351 Calls some simple checks that test the submitted $::form for obvious errors.
1352 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1354 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1355 some cases extra actions are taken, e.g. if the part description is missing the
1356 basic data tab is selected and the description input field is focussed.
1362 =item C<form_check_part_description_exists>
1364 =item C<form_check_assortment_items_exist>
1366 =item C<form_check_assortment_items_unique>
1368 =item C<form_check_assembly_items_exist>
1370 =item C<form_check_partnumber_is_unique>
1374 =head1 HELPER FUNCTIONS
1380 When submitting the form for saving, parses the transmitted form. Expects the
1384 $::form->{makemodels}
1385 $::form->{translations}
1387 $::form->{assemblies}
1388 $::form->{assortments}
1390 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1392 =item C<recalc_item_totals %params>
1394 Helper function for calculating the total lastcost and sellprice for assemblies
1395 or assortments according to their items, which are parsed from the current
1398 Is called whenever the qty of an item is changed or items are deleted.
1402 * part_type : 'assortment' or 'assembly' (mandatory)
1404 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1406 Depending on the price_type the lastcost sum or sellprice sum is returned.
1408 Doesn't work for recursive items.
1412 =head1 GET SET INITS
1414 There are get_set_inits for
1422 which parse $::form and automatically create an array of objects.
1424 These inits are used during saving and each time a new element is added.
1428 =item C<init_makemodels>
1430 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1431 $self->part->makemodels, ready to be saved.
1433 Used for saving parts and adding new makemodel rows.
1435 =item C<parse_add_items_to_objects PART_TYPE>
1437 Parses the resulting form from either the part-picker submit or the multi-item
1438 submit, and creates an arrayref of assortment_item or assembly objects, that
1439 can be rendered via C<render_assortment_items_to_html> or
1440 C<render_assembly_items_to_html>.
1442 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1443 Optional param: position (used for numbering and listrow class)
1445 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1447 Takes an array_ref of assortment_items, and generates tables rows ready for
1448 adding to the assortment table. Is used when a part is loaded, or whenever new
1449 assortment items are added.
1451 =item C<parse_form_makemodels>
1453 Makemodels can't just be overwritten, because of the field "lastupdate", that
1454 remembers when the lastcost for that vendor changed the last time.
1456 So the original values are cloned and remembered, so we can compare if lastcost
1457 was changed in $::form, and keep or update lastupdate.
1459 lastcost isn't updated until the first time it was saved with a value, until
1462 Also a boolean "makemodel" needs to be written in parts, depending on whether
1463 makemodel entries exist or not.
1465 We still need init_makemodels for when we open the part for editing.
1475 It should be possible to jump to the edit page in a specific tab
1479 Support callbacks, e.g. creating a new part from within an order, and jumping
1480 back to the order again afterwards.
1484 Support units when adding assembly items or assortment items. Currently the
1485 default unit of the item is always used.
1489 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1490 consists of other assemblies.
1496 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>