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 $self->part( SL::DB::Part->new_part );
47 sub action_add_service {
48 my ($self, %params) = @_;
50 $self->part( SL::DB::Part->new_service );
54 sub action_add_assembly {
55 my ($self, %params) = @_;
57 $self->part( SL::DB::Part->new_assembly );
61 sub action_add_assortment {
62 my ($self, %params) = @_;
64 $self->part( SL::DB::Part->new_assortment );
71 check_has_valid_part_type($::form->{part_type});
73 $self->action_add_part if $::form->{part_type} eq 'part';
74 $self->action_add_service if $::form->{part_type} eq 'service';
75 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
76 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
80 my ($self, %params) = @_;
82 # checks that depend only on submitted $::form
83 $self->check_form or return $self->js->render;
85 my $is_new = !$self->part->id; # $ part gets loaded here
87 # check that the part hasn't been modified
89 $self->check_part_not_modified or
90 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;
93 if ( $is_new and !$::form->{part}{partnumber} ) {
94 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
99 my @errors = $self->part->validate;
100 return $self->js->error(@errors)->render if @errors;
102 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
103 $self->part->db->with_transaction(sub {
105 if ( $params{save_as_new} ) {
106 $self->part( $self->part->clone_and_reset_deep );
107 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
110 $self->part->save(cascade => 1);
112 SL::DB::History->new(
113 trans_id => $self->part->id,
114 snumbers => 'partnumber_' . $self->part->partnumber,
115 employee_id => SL::DB::Manager::Employee->current->id,
120 CVar->save_custom_variables(
121 dbh => $self->part->db->dbh,
123 trans_id => $self->part->id,
124 variables => $::form, # $::form->{cvar} would be nicer
129 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
131 flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
133 # reload item, this also resets last_modification!
134 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
137 sub action_save_as_new {
139 $self->action_save(save_as_new=>1);
145 my $db = $self->part->db; # $self->part has a get_set_init on $::form
147 my $partnumber = $self->part->partnumber; # remember for history log
152 # delete part, together with relationships that don't already
153 # have an ON DELETE CASCADE, e.g. makemodel and translation.
154 $self->part->delete(cascade => 1);
156 SL::DB::History->new(
157 trans_id => $self->part->id,
158 snumbers => 'partnumber_' . $partnumber,
159 employee_id => SL::DB::Manager::Employee->current->id,
161 addition => 'DELETED',
164 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
166 flash_later('info', t8('The item has been deleted.'));
167 my @redirect_params = (
168 controller => 'controller.pl',
169 action => 'LoginScreen/user_login'
171 $self->redirect_to(@redirect_params);
174 sub action_use_as_new {
175 my ($self, %params) = @_;
177 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
178 $::form->{oldpartnumber} = $oldpart->partnumber;
180 $self->part($oldpart->clone_and_reset_deep);
182 $self->part->partnumber(undef);
188 my ($self, %params) = @_;
194 my ($self, %params) = @_;
196 $self->_set_javascript;
197 $self->_setup_form_action_bar;
199 my (%assortment_vars, %assembly_vars);
200 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
201 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
203 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
205 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
206 if (scalar @{ $params{CUSTOM_VARIABLES} });
208 my %title_hash = ( part => t8('Edit Part'),
209 assembly => t8('Edit Assembly'),
210 service => t8('Edit Service'),
211 assortment => t8('Edit Assortment'),
214 $self->part->prices([]) unless $self->part->prices;
215 $self->part->translations([]) unless $self->part->translations;
219 title => $title_hash{$self->part->part_type},
222 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
223 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
224 oldpartnumber => $::form->{oldpartnumber},
225 old_id => $::form->{old_id},
233 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
234 $_[0]->render('part/history', { layout => 0 },
235 history_entries => $history_entries);
238 sub action_update_item_totals {
241 my $part_type = $::form->{part_type};
242 die unless $part_type =~ /^(assortment|assembly)$/;
244 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
245 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
247 my $sum_diff = $sellprice_sum-$lastcost_sum;
250 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
251 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
252 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
253 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
254 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
255 ->no_flash_clear->render();
258 sub action_add_multi_assortment_items {
261 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
262 my $html = $self->render_assortment_items_to_html($item_objects);
264 $self->js->run('kivi.Part.close_picker_dialogs')
265 ->append('#assortment_rows', $html)
266 ->run('kivi.Part.renumber_positions')
267 ->run('kivi.Part.assortment_recalc')
271 sub action_add_multi_assembly_items {
274 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
276 foreach my $item (@{$item_objects}) {
277 my $errstr = validate_assembly($item->part,$self->part);
278 $self->js->flash('error',$errstr) if $errstr;
279 push (@checked_objects,$item) unless $errstr;
282 my $html = $self->render_assembly_items_to_html(\@checked_objects);
284 $self->js->run('kivi.Part.close_picker_dialogs')
285 ->append('#assembly_rows', $html)
286 ->run('kivi.Part.renumber_positions')
287 ->run('kivi.Part.assembly_recalc')
291 sub action_add_assortment_item {
292 my ($self, %params) = @_;
294 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
296 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
298 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
299 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
300 return $self->js->flash('error', t8("This part has already been added."))->render;
303 my $number_of_items = scalar @{$self->assortment_items};
304 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
305 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
307 push(@{$self->assortment_items}, @{$item_objects});
308 my $part = SL::DB::Part->new(part_type => 'assortment');
309 $part->assortment_items(@{$self->assortment_items});
310 my $items_sellprice_sum = $part->items_sellprice_sum;
311 my $items_lastcost_sum = $part->items_lastcost_sum;
312 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
315 ->append('#assortment_rows' , $html) # append in tbody
316 ->val('.add_assortment_item_input' , '')
317 ->run('kivi.Part.focus_last_assortment_input')
318 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
319 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
320 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
321 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
322 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
326 sub action_add_assembly_item {
329 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
331 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
333 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
335 my $duplicate_warning = 0; # duplicates are allowed, just warn
336 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
337 $duplicate_warning++;
340 my $number_of_items = scalar @{$self->assembly_items};
341 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
343 foreach my $item (@{$item_objects}) {
344 my $errstr = validate_assembly($item->part,$self->part);
345 return $self->js->flash('error',$errstr)->render if $errstr;
350 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
352 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
354 push(@{$self->assembly_items}, @{$item_objects});
355 my $part = SL::DB::Part->new(part_type => 'assembly');
356 $part->assemblies(@{$self->assembly_items});
357 my $items_sellprice_sum = $part->items_sellprice_sum;
358 my $items_lastcost_sum = $part->items_lastcost_sum;
359 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
362 ->append('#assembly_rows', $html) # append in tbody
363 ->val('.add_assembly_item_input' , '')
364 ->run('kivi.Part.focus_last_assembly_input')
365 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
366 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
367 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
368 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
369 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
373 sub action_show_multi_items_dialog {
374 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
375 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
379 sub action_multi_items_update_result {
382 $::form->{multi_items}->{filter}->{obsolete} = 0;
384 my $count = $_[0]->multi_items_models->count;
387 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
388 $_[0]->render($text, { layout => 0 });
389 } elsif ($count > $max_count) {
390 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
391 $_[0]->render($text, { layout => 0 });
393 my $multi_items = $_[0]->multi_items_models->get;
394 $_[0]->render('part/_multi_items_result', { layout => 0 },
395 multi_items => $multi_items);
399 sub action_add_makemodel_row {
402 my $vendor_id = $::form->{add_makemodel};
404 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
405 return $self->js->error(t8("No vendor selected or found!"))->render;
407 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
408 $self->js->flash('info', t8("This vendor has already been added."));
411 my $position = scalar @{$self->makemodels} + 1;
413 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
417 sortorder => $position,
418 ) or die "Can't create MakeModel object";
420 my $row_as_html = $self->p->render('part/_makemodel_row',
422 listrow => $position % 2 ? 0 : 1,
425 # after selection focus on the model field in the row that was just added
427 ->append('#makemodel_rows', $row_as_html) # append in tbody
428 ->val('.add_makemodel_input', '')
429 ->run('kivi.Part.focus_last_makemodel_input')
433 sub action_reorder_items {
436 my $part_type = $::form->{part_type};
439 partnumber => sub { $_[0]->part->partnumber },
440 description => sub { $_[0]->part->description },
441 qty => sub { $_[0]->qty },
442 sellprice => sub { $_[0]->part->sellprice },
443 lastcost => sub { $_[0]->part->lastcost },
444 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
447 my $method = $sort_keys{$::form->{order_by}};
450 if ($part_type eq 'assortment') {
451 @items = @{ $self->assortment_items };
453 @items = @{ $self->assembly_items };
456 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
457 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
458 if ($::form->{sort_dir}) {
459 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
461 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
464 if ($::form->{sort_dir}) {
465 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
467 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
471 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
474 sub action_warehouse_changed {
477 if ($::form->{warehouse_id} ) {
478 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
479 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
481 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
482 $self->bin($self->warehouse->bins->[0]);
484 ->html('#bin', $self->build_bin_select)
485 ->focus('#part_bin_id');
486 return $self->js->render;
490 # no warehouse was selected, empty the bin field and reset the id
492 ->val('#part_bin_id', undef)
495 return $self->js->render;
498 sub action_ajax_autocomplete {
499 my ($self, %params) = @_;
501 # if someone types something, and hits enter, assume he entered the full name.
502 # if something matches, treat that as sole match
503 # since we need a second get models instance with different filters for that,
504 # we only modify the original filter temporarily in place
505 if ($::form->{prefer_exact}) {
506 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
508 my $exact_models = SL::Controller::Helper::GetModels->new(
511 paginated => { per_page => 2 },
512 with_objects => [ qw(unit_obj classification) ],
515 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
516 $self->parts($exact_matches);
522 value => $_->displayable_name,
523 label => $_->displayable_name,
525 partnumber => $_->partnumber,
526 description => $_->description,
527 part_type => $_->part_type,
529 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
531 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
533 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
536 sub action_test_page {
537 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
540 sub action_part_picker_search {
541 $_[0]->render('part/part_picker_search', { layout => 0 });
544 sub action_part_picker_result {
545 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
551 if ($::request->type eq 'json') {
556 $part_hash = $self->part->as_tree;
557 $part_hash->{cvars} = $self->part->cvar_as_hashref;
560 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
565 sub validate_add_items {
566 scalar @{$::form->{add_items}};
569 sub prepare_assortment_render_vars {
572 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
573 items_lastcost_sum => $self->part->items_lastcost_sum,
574 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
576 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
581 sub prepare_assembly_render_vars {
584 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
585 items_lastcost_sum => $self->part->items_lastcost_sum,
586 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
588 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
596 check_has_valid_part_type($self->part->part_type);
598 $self->_set_javascript;
599 $self->_setup_form_action_bar;
601 my %title_hash = ( part => t8('Add Part'),
602 assembly => t8('Add Assembly'),
603 service => t8('Add Service'),
604 assortment => t8('Add Assortment'),
609 title => $title_hash{$self->part->part_type},
614 sub _set_javascript {
616 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
617 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
620 sub recalc_item_totals {
621 my ($self, %params) = @_;
623 if ( $params{part_type} eq 'assortment' ) {
624 return 0 unless scalar @{$self->assortment_items};
625 } elsif ( $params{part_type} eq 'assembly' ) {
626 return 0 unless scalar @{$self->assembly_items};
628 carp "can only calculate sum for assortments and assemblies";
631 my $part = SL::DB::Part->new(part_type => $params{part_type});
632 if ( $part->is_assortment ) {
633 $part->assortment_items( @{$self->assortment_items} );
634 if ( $params{price_type} eq 'lastcost' ) {
635 return $part->items_lastcost_sum;
637 if ( $params{pricegroup_id} ) {
638 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
640 return $part->items_sellprice_sum;
643 } elsif ( $part->is_assembly ) {
644 $part->assemblies( @{$self->assembly_items} );
645 if ( $params{price_type} eq 'lastcost' ) {
646 return $part->items_lastcost_sum;
648 return $part->items_sellprice_sum;
653 sub check_part_not_modified {
656 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
663 my $is_new = !$self->part->id;
665 my $params = delete($::form->{part}) || { };
667 delete $params->{id};
668 # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
669 delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
670 $self->part->assign_attributes(%{ $params});
671 $self->part->bin_id(undef) unless $self->part->warehouse_id;
673 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
674 # will be the case for used assortments when saving, or when a used assortment
676 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
677 $self->part->assortment_items([]);
678 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
681 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
682 $self->part->assemblies([]); # completely rewrite assortments each time
683 $self->part->add_assemblies( @{ $self->assembly_items } );
686 $self->part->translations([]);
687 $self->parse_form_translations;
689 $self->part->prices([]);
690 $self->parse_form_prices;
692 $self->parse_form_makemodels;
695 sub parse_form_prices {
697 # only save prices > 0
698 my $prices = delete($::form->{prices}) || [];
699 foreach my $price ( @{$prices} ) {
700 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
701 next unless $sellprice > 0; # skip negative prices as well
702 my $p = SL::DB::Price->new(parts_id => $self->part->id,
703 pricegroup_id => $price->{pricegroup_id},
706 $self->part->add_prices($p);
710 sub parse_form_translations {
712 # don't add empty translations
713 my $translations = delete($::form->{translations}) || [];
714 foreach my $translation ( @{$translations} ) {
715 next unless $translation->{translation};
716 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
717 $self->part->add_translations( $translation );
721 sub parse_form_makemodels {
725 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
726 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
729 $self->part->makemodels([]);
732 my $makemodels = delete($::form->{makemodels}) || [];
733 foreach my $makemodel ( @{$makemodels} ) {
734 next unless $makemodel->{make};
736 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
738 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
739 id => $makemodel->{id},
740 make => $makemodel->{make},
741 model => $makemodel->{model} || '',
742 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
743 sortorder => $position,
745 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
746 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
747 # don't change lastupdate
748 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
749 # new makemodel, no lastcost entered, leave lastupdate empty
750 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
751 # lastcost hasn't changed, use original lastupdate
752 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
754 $mm->lastupdate(DateTime->now);
756 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
757 $self->part->add_makemodels($mm);
761 sub build_bin_select {
762 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
763 title_key => 'description',
764 default => $_[0]->bin->id,
768 # get_set_inits for partpicker
771 if ($::form->{no_paginate}) {
772 $_[0]->models->disable_plugin('paginated');
778 # get_set_inits for part controller
782 # used by edit, save, delete and add
784 if ( $::form->{part}{id} ) {
785 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
787 die "part_type missing" unless $::form->{part}{part_type};
788 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
794 return $self->part->orphaned;
800 SL::Controller::Helper::GetModels->new(
807 partnumber => t8('Partnumber'),
808 description => t8('Description'),
810 with_objects => [ qw(unit_obj classification) ],
819 sub init_assortment_items {
820 # this init is used while saving and whenever assortments change dynamically
824 my $assortment_items = delete($::form->{assortment_items}) || [];
825 foreach my $assortment_item ( @{$assortment_items} ) {
826 next unless $assortment_item->{parts_id};
828 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
829 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
830 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
831 charge => $assortment_item->{charge},
832 unit => $assortment_item->{unit} || $part->unit,
833 position => $position,
841 sub init_makemodels {
845 my @makemodel_array = ();
846 my $makemodels = delete($::form->{makemodels}) || [];
848 foreach my $makemodel ( @{$makemodels} ) {
849 next unless $makemodel->{make};
851 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
852 id => $makemodel->{id},
853 make => $makemodel->{make},
854 model => $makemodel->{model} || '',
855 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
856 sortorder => $position,
857 ) or die "Can't create mm";
858 # $mm->id($makemodel->{id}) if $makemodel->{id};
859 push(@makemodel_array, $mm);
861 return \@makemodel_array;
864 sub init_assembly_items {
868 my $assembly_items = delete($::form->{assembly_items}) || [];
869 foreach my $assembly_item ( @{$assembly_items} ) {
870 next unless $assembly_item->{parts_id};
872 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
873 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
874 bom => $assembly_item->{bom},
875 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
876 position => $position,
883 sub init_all_warehouses {
885 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
888 sub init_all_languages {
889 SL::DB::Manager::Language->get_all_sorted;
892 sub init_all_partsgroups {
894 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
897 sub init_all_buchungsgruppen {
899 if ( $self->part->orphaned ) {
900 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
902 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
908 if ( $self->part->orphaned ) {
909 return SL::DB::Manager::Unit->get_all_sorted;
911 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
915 sub init_all_payment_terms {
917 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
920 sub init_all_price_factors {
921 SL::DB::Manager::PriceFactor->get_all_sorted;
924 sub init_all_pricegroups {
925 SL::DB::Manager::Pricegroup->get_all_sorted;
928 # model used to filter/display the parts in the multi-items dialog
929 sub init_multi_items_models {
930 SL::Controller::Helper::GetModels->new(
933 with_objects => [ qw(unit_obj partsgroup classification) ],
934 disable_plugin => 'paginated',
935 source => $::form->{multi_items},
941 partnumber => t8('Partnumber'),
942 description => t8('Description')}
946 # simple checks to run on $::form before saving
948 sub form_check_part_description_exists {
951 return 1 if $::form->{part}{description};
953 $self->js->flash('error', t8('Part Description missing!'))
954 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
955 ->focus('#part_description');
959 sub form_check_assortment_items_exist {
962 return 1 unless $::form->{part}{part_type} eq 'assortment';
963 # skip item check for existing assortments that have been used
964 return 1 if ($self->part->id and !$self->part->orphaned);
966 # new or orphaned parts must have items in $::form->{assortment_items}
967 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
968 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
969 ->focus('#add_assortment_item_name')
970 ->flash('error', t8('The assortment doesn\'t have any items.'));
976 sub form_check_assortment_items_unique {
979 return 1 unless $::form->{part}{part_type} eq 'assortment';
981 my %duplicate_elements;
983 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
984 $duplicate_elements{$_}++ if $count{$_}++;
987 if ( keys %duplicate_elements ) {
988 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
989 ->flash('error', t8('There are duplicate assortment items'));
995 sub form_check_assembly_items_exist {
998 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1000 # skip item check for existing assembly that have been used
1001 return 1 if ($self->part->id and !$self->part->orphaned);
1003 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1004 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1005 ->focus('#add_assembly_item_name')
1006 ->flash('error', t8('The assembly doesn\'t have any items.'));
1012 sub form_check_partnumber_is_unique {
1015 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1016 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1018 $self->js->flash('error', t8('The partnumber already exists!'))
1019 ->focus('#part_description');
1026 # general checking functions
1027 sub check_next_transnumber_is_free {
1030 my ($next_transnumber, $count);
1031 $self->part->db->with_transaction(sub {
1032 $next_transnumber = $self->part->get_next_trans_number;
1033 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1036 $count ? return 0 : return 1;
1040 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1046 $self->form_check_part_description_exists || return 0;
1047 $self->form_check_assortment_items_exist || return 0;
1048 $self->form_check_assortment_items_unique || return 0;
1049 $self->form_check_assembly_items_exist || return 0;
1050 $self->form_check_partnumber_is_unique || return 0;
1055 sub check_has_valid_part_type {
1056 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1059 sub render_assortment_items_to_html {
1060 my ($self, $assortment_items, $number_of_items) = @_;
1062 my $position = $number_of_items + 1;
1064 foreach my $ai (@$assortment_items) {
1065 $html .= $self->p->render('part/_assortment_row',
1066 PART => $self->part,
1067 orphaned => $self->orphaned,
1069 listrow => $position % 2 ? 1 : 0,
1070 position => $position, # for legacy assemblies
1077 sub render_assembly_items_to_html {
1078 my ($self, $assembly_items, $number_of_items) = @_;
1080 my $position = $number_of_items + 1;
1082 foreach my $ai (@{$assembly_items}) {
1083 $html .= $self->p->render('part/_assembly_row',
1084 PART => $self->part,
1085 orphaned => $self->orphaned,
1087 listrow => $position % 2 ? 1 : 0,
1088 position => $position, # for legacy assemblies
1095 sub parse_add_items_to_objects {
1096 my ($self, %params) = @_;
1097 my $part_type = $params{part_type};
1098 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1099 my $position = $params{position} || 1;
1101 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1104 foreach my $item ( @add_items ) {
1105 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1107 if ( $part_type eq 'assortment' ) {
1108 $ai = SL::DB::AssortmentItem->new(part => $part,
1109 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1110 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1111 position => $position,
1112 ) or die "Can't create AssortmentItem from item";
1113 } elsif ( $part_type eq 'assembly' ) {
1114 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1115 # id => $self->assembly->id, # will be set on save
1116 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1117 bom => 0, # default when adding: no bom
1118 position => $position,
1121 die "part_type must be assortment or assembly";
1123 push(@item_objects, $ai);
1127 return \@item_objects;
1130 sub _setup_form_action_bar {
1133 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1135 for my $bar ($::request->layout->get('actionbar')) {
1140 call => [ 'kivi.Part.save' ],
1141 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1145 call => [ 'kivi.Part.use_as_new' ],
1146 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1147 : !$may_edit ? t8('You do not have the permissions to access this function.')
1150 ], # end of combobox "Save"
1154 call => [ 'kivi.Part.delete' ],
1155 confirm => t8('Do you really want to delete this object?'),
1156 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1157 : !$may_edit ? t8('You do not have the permissions to access this function.')
1158 : !$self->part->orphaned ? t8('This object has already been used.')
1166 call => [ 'kivi.Part.open_history_popup' ],
1167 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1168 : !$may_edit ? t8('You do not have the permissions to access this function.')
1183 SL::Controller::Part - Part CRUD controller
1187 Controller for adding/editing/saving/deleting parts.
1189 All the relations are loaded at once and saving the part, adding a history
1190 entry and saving CVars happens inside one transaction. When saving the old
1191 relations are deleted and written as new to the database.
1193 Relations for parts:
1201 =item assembly items
1203 =item assortment items
1211 There are 4 different part types:
1217 The "default" part type.
1219 inventory_accno_id is set.
1223 Services can't be stocked.
1225 inventory_accno_id isn't set.
1229 Assemblies consist of other parts, services, assemblies or assortments. They
1230 aren't meant to be bought, only sold. To add assemblies to stock you typically
1231 have to make them, which reduces the stock by its respective components. Once
1232 an assembly item has been created there is currently no way to "disassemble" it
1233 again. An assembly item can appear several times in one assembly. An assmbly is
1234 sold as one item with a defined sellprice and lastcost. If the component prices
1235 change the assortment price remains the same. The assembly items may be printed
1236 in a record if the item's "bom" is set.
1240 Similar to assembly, but each assortment item may only appear once per
1241 assortment. When selling an assortment the assortment items are added to the
1242 record together with the assortment, which is added with sellprice 0.
1244 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1245 determined by the sum of the current assortment item prices when the assortment
1246 is added to a record. This also means that price rules and customer discounts
1247 will be applied to the assortment items.
1249 Once the assortment items have been added they may be modified or deleted, just
1250 as if they had been added manually, the individual assortment items aren't
1251 linked to the assortment or the other assortment items in any way.
1259 =item C<action_add_part>
1261 =item C<action_add_service>
1263 =item C<action_add_assembly>
1265 =item C<action_add_assortment>
1267 =item C<action_add PART_TYPE>
1269 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1270 parameter part_type as an action. Example:
1272 controller.pl?action=Part/add&part_type=service
1274 =item C<action_save>
1276 Saves the current part and then reloads the edit page for the part.
1278 =item C<action_use_as_new>
1280 Takes the information from the current part, plus any modifications made on the
1281 page, and creates a new edit page that is ready to be saved. The partnumber is
1282 set empty, so a new partnumber from the number range will be used if the user
1283 doesn't enter one manually.
1285 Unsaved changes to the original part aren't updated.
1287 The part type cannot be changed in this way.
1289 =item C<action_delete>
1291 Deletes the current part and then redirects to the main page, there is no
1294 The delete button only appears if the part is 'orphaned', according to
1295 SL::DB::Part orphaned.
1297 The part can't be deleted if it appears in invoices, orders, delivery orders,
1298 the inventory, or is part of an assembly or assortment.
1300 If the part is deleted its relations prices, makdemodel, assembly,
1301 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1303 Before this controller items that appeared in inventory didn't count as
1304 orphaned and could be deleted and the inventory entries were also deleted, this
1305 "feature" hasn't been implemented.
1307 =item C<action_edit part.id>
1309 Load and display a part for editing.
1311 controller.pl?action=Part/edit&part.id=12345
1313 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1317 =head1 BUTTON ACTIONS
1323 Opens a popup displaying all the history entries. Once a new history controller
1324 is written the button could link there instead, with the part already selected.
1332 =item C<action_update_item_totals>
1334 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1335 amount of an item changes. The sum of all sellprices and lastcosts is
1336 calculated and the totals updated. Uses C<recalc_item_totals>.
1338 =item C<action_add_assortment_item>
1340 Adds a new assortment item from a part picker seleciton to the assortment item list
1342 If the item already exists in the assortment the item isn't added and a Flash
1345 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1346 after adding each new item, add the new object to the item objects that were
1347 already parsed, calculate totals via a dummy part then update the row and the
1350 =item C<action_add_assembly_item>
1352 Adds a new assembly item from a part picker seleciton to the assembly item list
1354 If the item already exists in the assembly a flash info is generated, but the
1357 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1358 after adding each new item, add the new object to the item objects that were
1359 already parsed, calculate totals via a dummy part then update the row and the
1362 =item C<action_add_multi_assortment_items>
1364 Parses the items to be added from the form generated by the multi input and
1365 appends the html of the tr-rows to the assortment item table. Afterwards all
1366 assortment items are renumbered and the sums recalculated via
1367 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1369 =item C<action_add_multi_assembly_items>
1371 Parses the items to be added from the form generated by the multi input and
1372 appends the html of the tr-rows to the assembly item table. Afterwards all
1373 assembly items are renumbered and the sums recalculated via
1374 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1376 =item C<action_show_multi_items_dialog>
1378 =item C<action_multi_items_update_result>
1380 =item C<action_add_makemodel_row>
1382 Add a new makemodel row with the vendor that was selected via the vendor
1385 Checks the already existing makemodels and warns if a row with that vendor
1386 already exists. Currently it is possible to have duplicate vendor rows.
1388 =item C<action_reorder_items>
1390 Sorts the item table for assembly or assortment items.
1392 =item C<action_warehouse_changed>
1396 =head1 ACTIONS part picker
1400 =item C<action_ajax_autocomplete>
1402 =item C<action_test_page>
1404 =item C<action_part_picker_search>
1406 =item C<action_part_picker_result>
1408 =item C<action_show>
1418 Calls some simple checks that test the submitted $::form for obvious errors.
1419 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1421 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1422 some cases extra actions are taken, e.g. if the part description is missing the
1423 basic data tab is selected and the description input field is focussed.
1429 =item C<form_check_part_description_exists>
1431 =item C<form_check_assortment_items_exist>
1433 =item C<form_check_assortment_items_unique>
1435 =item C<form_check_assembly_items_exist>
1437 =item C<form_check_partnumber_is_unique>
1441 =head1 HELPER FUNCTIONS
1447 When submitting the form for saving, parses the transmitted form. Expects the
1451 $::form->{makemodels}
1452 $::form->{translations}
1454 $::form->{assemblies}
1455 $::form->{assortments}
1457 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1459 =item C<recalc_item_totals %params>
1461 Helper function for calculating the total lastcost and sellprice for assemblies
1462 or assortments according to their items, which are parsed from the current
1465 Is called whenever the qty of an item is changed or items are deleted.
1469 * part_type : 'assortment' or 'assembly' (mandatory)
1471 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1473 Depending on the price_type the lastcost sum or sellprice sum is returned.
1475 Doesn't work for recursive items.
1479 =head1 GET SET INITS
1481 There are get_set_inits for
1489 which parse $::form and automatically create an array of objects.
1491 These inits are used during saving and each time a new element is added.
1495 =item C<init_makemodels>
1497 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1498 $self->part->makemodels, ready to be saved.
1500 Used for saving parts and adding new makemodel rows.
1502 =item C<parse_add_items_to_objects PART_TYPE>
1504 Parses the resulting form from either the part-picker submit or the multi-item
1505 submit, and creates an arrayref of assortment_item or assembly objects, that
1506 can be rendered via C<render_assortment_items_to_html> or
1507 C<render_assembly_items_to_html>.
1509 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1510 Optional param: position (used for numbering and listrow class)
1512 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1514 Takes an array_ref of assortment_items, and generates tables rows ready for
1515 adding to the assortment table. Is used when a part is loaded, or whenever new
1516 assortment items are added.
1518 =item C<parse_form_makemodels>
1520 Makemodels can't just be overwritten, because of the field "lastupdate", that
1521 remembers when the lastcost for that vendor changed the last time.
1523 So the original values are cloned and remembered, so we can compare if lastcost
1524 was changed in $::form, and keep or update lastupdate.
1526 lastcost isn't updated until the first time it was saved with a value, until
1529 Also a boolean "makemodel" needs to be written in parts, depending on whether
1530 makemodel entries exist or not.
1532 We still need init_makemodels for when we open the part for editing.
1542 It should be possible to jump to the edit page in a specific tab
1546 Support callbacks, e.g. creating a new part from within an order, and jumping
1547 back to the order again afterwards.
1551 Support units when adding assembly items or assortment items. Currently the
1552 default unit of the item is always used.
1556 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1557 consists of other assemblies.
1563 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>