1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
10 use SL::Controller::Helper::GetModels;
11 use SL::Locale::String qw(t8);
13 use List::Util qw(sum);
14 use SL::Helper::Flash;
18 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
20 use SL::MoreCommon qw(save_form);
22 use SL::Presenter::EscapedText qw(escape is_escaped);
24 use Rose::Object::MakeMethods::Generic (
25 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
26 makemodels shops_not_assigned
28 assortment assortment_items assembly assembly_items
29 all_pricegroups all_translations all_partsgroups all_units
30 all_buchungsgruppen all_payment_terms all_warehouses
31 parts_classification_filter
32 all_languages all_units all_price_factors) ],
33 'scalar' => [ qw(warehouse bin) ],
37 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
38 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
40 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
42 # actions for editing parts
45 my ($self, %params) = @_;
47 $self->part( SL::DB::Part->new_part );
51 sub action_add_service {
52 my ($self, %params) = @_;
54 $self->part( SL::DB::Part->new_service );
58 sub action_add_assembly {
59 my ($self, %params) = @_;
61 $self->part( SL::DB::Part->new_assembly );
65 sub action_add_assortment {
66 my ($self, %params) = @_;
68 $self->part( SL::DB::Part->new_assortment );
72 sub action_add_from_record {
75 check_has_valid_part_type($::form->{part}{part_type});
77 die 'parts_classification_type must be "sales" or "purchases"'
78 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
87 check_has_valid_part_type($::form->{part_type});
89 $self->action_add_part if $::form->{part_type} eq 'part';
90 $self->action_add_service if $::form->{part_type} eq 'service';
91 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
92 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
96 my ($self, %params) = @_;
98 # checks that depend only on submitted $::form
99 $self->check_form or return $self->js->render;
101 my $is_new = !$self->part->id; # $ part gets loaded here
103 # check that the part hasn't been modified
105 $self->check_part_not_modified or
106 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;
110 && $::form->{part}{partnumber}
111 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
113 return $self->js->error(t8('The partnumber is already being used'))->render;
118 my @errors = $self->part->validate;
119 return $self->js->error(@errors)->render if @errors;
121 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
122 $self->part->db->with_transaction(sub {
124 if ( $params{save_as_new} ) {
125 $self->part( $self->part->clone_and_reset_deep );
126 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
129 $self->part->save(cascade => 1);
131 SL::DB::History->new(
132 trans_id => $self->part->id,
133 snumbers => 'partnumber_' . $self->part->partnumber,
134 employee_id => SL::DB::Manager::Employee->current->id,
139 CVar->save_custom_variables(
140 dbh => $self->part->db->dbh,
142 trans_id => $self->part->id,
143 variables => $::form, # $::form->{cvar} would be nicer
148 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
151 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
153 if ( $::form->{callback} ) {
154 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
157 # default behaviour after save: reload item, this also resets last_modification!
158 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
162 sub action_save_as_new {
164 $self->action_save(save_as_new=>1);
170 my $db = $self->part->db; # $self->part has a get_set_init on $::form
172 my $partnumber = $self->part->partnumber; # remember for history log
177 # delete part, together with relationships that don't already
178 # have an ON DELETE CASCADE, e.g. makemodel and translation.
179 $self->part->delete(cascade => 1);
181 SL::DB::History->new(
182 trans_id => $self->part->id,
183 snumbers => 'partnumber_' . $partnumber,
184 employee_id => SL::DB::Manager::Employee->current->id,
186 addition => 'DELETED',
189 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
191 flash_later('info', t8('The item has been deleted.'));
192 if ( $::form->{callback} ) {
193 $self->redirect_to($::form->unescape($::form->{callback}));
195 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
199 sub action_use_as_new {
200 my ($self, %params) = @_;
202 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
203 $::form->{oldpartnumber} = $oldpart->partnumber;
205 $self->part($oldpart->clone_and_reset_deep);
207 $self->part->partnumber(undef);
213 my ($self, %params) = @_;
219 my ($self, %params) = @_;
221 $self->_set_javascript;
222 $self->_setup_form_action_bar;
224 my (%assortment_vars, %assembly_vars);
225 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
226 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
228 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
230 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
231 if (scalar @{ $params{CUSTOM_VARIABLES} });
233 my %title_hash = ( part => t8('Edit Part'),
234 assembly => t8('Edit Assembly'),
235 service => t8('Edit Service'),
236 assortment => t8('Edit Assortment'),
239 $self->part->prices([]) unless $self->part->prices;
240 $self->part->translations([]) unless $self->part->translations;
244 title => $title_hash{$self->part->part_type},
247 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
248 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
249 oldpartnumber => $::form->{oldpartnumber},
250 old_id => $::form->{old_id},
258 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
259 $_[0]->render('part/history', { layout => 0 },
260 history_entries => $history_entries);
263 sub action_update_item_totals {
266 my $part_type = $::form->{part_type};
267 die unless $part_type =~ /^(assortment|assembly)$/;
269 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
270 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
272 my $sum_diff = $sellprice_sum-$lastcost_sum;
275 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
276 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
277 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
278 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
279 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
280 ->no_flash_clear->render();
283 sub action_add_multi_assortment_items {
286 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
287 my $html = $self->render_assortment_items_to_html($item_objects);
289 $self->js->run('kivi.Part.close_picker_dialogs')
290 ->append('#assortment_rows', $html)
291 ->run('kivi.Part.renumber_positions')
292 ->run('kivi.Part.assortment_recalc')
296 sub action_add_multi_assembly_items {
299 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
301 foreach my $item (@{$item_objects}) {
302 my $errstr = validate_assembly($item->part,$self->part);
303 $self->js->flash('error',$errstr) if $errstr;
304 push (@checked_objects,$item) unless $errstr;
307 my $html = $self->render_assembly_items_to_html(\@checked_objects);
309 $self->js->run('kivi.Part.close_picker_dialogs')
310 ->append('#assembly_rows', $html)
311 ->run('kivi.Part.renumber_positions')
312 ->run('kivi.Part.assembly_recalc')
316 sub action_add_assortment_item {
317 my ($self, %params) = @_;
319 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
321 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
323 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
324 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
325 return $self->js->flash('error', t8("This part has already been added."))->render;
328 my $number_of_items = scalar @{$self->assortment_items};
329 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
330 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
332 push(@{$self->assortment_items}, @{$item_objects});
333 my $part = SL::DB::Part->new(part_type => 'assortment');
334 $part->assortment_items(@{$self->assortment_items});
335 my $items_sellprice_sum = $part->items_sellprice_sum;
336 my $items_lastcost_sum = $part->items_lastcost_sum;
337 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
340 ->append('#assortment_rows' , $html) # append in tbody
341 ->val('.add_assortment_item_input' , '')
342 ->run('kivi.Part.focus_last_assortment_input')
343 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
344 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
345 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
346 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
347 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
351 sub action_add_assembly_item {
354 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
356 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
358 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
360 my $duplicate_warning = 0; # duplicates are allowed, just warn
361 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
362 $duplicate_warning++;
365 my $number_of_items = scalar @{$self->assembly_items};
366 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
368 foreach my $item (@{$item_objects}) {
369 my $errstr = validate_assembly($item->part,$self->part);
370 return $self->js->flash('error',$errstr)->render if $errstr;
375 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
377 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
379 push(@{$self->assembly_items}, @{$item_objects});
380 my $part = SL::DB::Part->new(part_type => 'assembly');
381 $part->assemblies(@{$self->assembly_items});
382 my $items_sellprice_sum = $part->items_sellprice_sum;
383 my $items_lastcost_sum = $part->items_lastcost_sum;
384 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
387 ->append('#assembly_rows', $html) # append in tbody
388 ->val('.add_assembly_item_input' , '')
389 ->run('kivi.Part.focus_last_assembly_input')
390 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
391 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
392 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
393 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
394 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
398 sub action_show_multi_items_dialog {
399 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
400 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
404 sub action_multi_items_update_result {
407 $::form->{multi_items}->{filter}->{obsolete} = 0;
409 my $count = $_[0]->multi_items_models->count;
412 my $text = escape($::locale->text('No results.'));
413 $_[0]->render($text, { layout => 0 });
414 } elsif ($count > $max_count) {
415 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
416 $_[0]->render($text, { layout => 0 });
418 my $multi_items = $_[0]->multi_items_models->get;
419 $_[0]->render('part/_multi_items_result', { layout => 0 },
420 multi_items => $multi_items);
424 sub action_add_makemodel_row {
427 my $vendor_id = $::form->{add_makemodel};
429 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
430 return $self->js->error(t8("No vendor selected or found!"))->render;
432 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
433 $self->js->flash('info', t8("This vendor has already been added."));
436 my $position = scalar @{$self->makemodels} + 1;
438 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
442 sortorder => $position,
443 ) or die "Can't create MakeModel object";
445 my $row_as_html = $self->p->render('part/_makemodel_row',
447 listrow => $position % 2 ? 0 : 1,
450 # after selection focus on the model field in the row that was just added
452 ->append('#makemodel_rows', $row_as_html) # append in tbody
453 ->val('.add_makemodel_input', '')
454 ->run('kivi.Part.focus_last_makemodel_input')
458 sub action_reorder_items {
461 my $part_type = $::form->{part_type};
464 partnumber => sub { $_[0]->part->partnumber },
465 description => sub { $_[0]->part->description },
466 qty => sub { $_[0]->qty },
467 sellprice => sub { $_[0]->part->sellprice },
468 lastcost => sub { $_[0]->part->lastcost },
469 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
472 my $method = $sort_keys{$::form->{order_by}};
475 if ($part_type eq 'assortment') {
476 @items = @{ $self->assortment_items };
478 @items = @{ $self->assembly_items };
481 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
482 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
483 if ($::form->{sort_dir}) {
484 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
486 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
489 if ($::form->{sort_dir}) {
490 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
492 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
496 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
499 sub action_warehouse_changed {
502 if ($::form->{warehouse_id} ) {
503 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
504 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
506 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
507 $self->bin($self->warehouse->bins->[0]);
509 ->html('#bin', $self->build_bin_select)
510 ->focus('#part_bin_id');
511 return $self->js->render;
515 # no warehouse was selected, empty the bin field and reset the id
517 ->val('#part_bin_id', undef)
520 return $self->js->render;
523 sub action_ajax_autocomplete {
524 my ($self, %params) = @_;
526 # if someone types something, and hits enter, assume he entered the full name.
527 # if something matches, treat that as sole match
528 # since we need a second get models instance with different filters for that,
529 # we only modify the original filter temporarily in place
530 if ($::form->{prefer_exact}) {
531 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
533 my $exact_models = SL::Controller::Helper::GetModels->new(
536 paginated => { per_page => 2 },
537 with_objects => [ qw(unit_obj classification) ],
540 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
541 $self->parts($exact_matches);
547 value => $_->displayable_name,
548 label => $_->displayable_name,
550 partnumber => $_->partnumber,
551 description => $_->description,
552 part_type => $_->part_type,
554 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
556 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
558 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
561 sub action_test_page {
562 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
565 sub action_part_picker_search {
566 $_[0]->render('part/part_picker_search', { layout => 0 });
569 sub action_part_picker_result {
570 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
576 if ($::request->type eq 'json') {
581 $part_hash = $self->part->as_tree;
582 $part_hash->{cvars} = $self->part->cvar_as_hashref;
585 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
590 sub validate_add_items {
591 scalar @{$::form->{add_items}};
594 sub prepare_assortment_render_vars {
597 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
598 items_lastcost_sum => $self->part->items_lastcost_sum,
599 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
601 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
606 sub prepare_assembly_render_vars {
609 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
610 items_lastcost_sum => $self->part->items_lastcost_sum,
611 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
613 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
621 check_has_valid_part_type($self->part->part_type);
623 $self->_set_javascript;
624 $self->_setup_form_action_bar;
626 my %title_hash = ( part => t8('Add Part'),
627 assembly => t8('Add Assembly'),
628 service => t8('Add Service'),
629 assortment => t8('Add Assortment'),
634 title => $title_hash{$self->part->part_type},
639 sub _set_javascript {
641 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
642 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
645 sub recalc_item_totals {
646 my ($self, %params) = @_;
648 if ( $params{part_type} eq 'assortment' ) {
649 return 0 unless scalar @{$self->assortment_items};
650 } elsif ( $params{part_type} eq 'assembly' ) {
651 return 0 unless scalar @{$self->assembly_items};
653 carp "can only calculate sum for assortments and assemblies";
656 my $part = SL::DB::Part->new(part_type => $params{part_type});
657 if ( $part->is_assortment ) {
658 $part->assortment_items( @{$self->assortment_items} );
659 if ( $params{price_type} eq 'lastcost' ) {
660 return $part->items_lastcost_sum;
662 if ( $params{pricegroup_id} ) {
663 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
665 return $part->items_sellprice_sum;
668 } elsif ( $part->is_assembly ) {
669 $part->assemblies( @{$self->assembly_items} );
670 if ( $params{price_type} eq 'lastcost' ) {
671 return $part->items_lastcost_sum;
673 return $part->items_sellprice_sum;
678 sub check_part_not_modified {
681 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
688 my $is_new = !$self->part->id;
690 my $params = delete($::form->{part}) || { };
692 delete $params->{id};
693 $self->part->assign_attributes(%{ $params});
694 $self->part->bin_id(undef) unless $self->part->warehouse_id;
696 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
697 # will be the case for used assortments when saving, or when a used assortment
699 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
700 $self->part->assortment_items([]);
701 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
704 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
705 $self->part->assemblies([]); # completely rewrite assortments each time
706 $self->part->add_assemblies( @{ $self->assembly_items } );
709 $self->part->translations([]);
710 $self->parse_form_translations;
712 $self->part->prices([]);
713 $self->parse_form_prices;
715 $self->parse_form_makemodels;
718 sub parse_form_prices {
720 # only save prices > 0
721 my $prices = delete($::form->{prices}) || [];
722 foreach my $price ( @{$prices} ) {
723 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
724 next unless $sellprice > 0; # skip negative prices as well
725 my $p = SL::DB::Price->new(parts_id => $self->part->id,
726 pricegroup_id => $price->{pricegroup_id},
729 $self->part->add_prices($p);
733 sub parse_form_translations {
735 # don't add empty translations
736 my $translations = delete($::form->{translations}) || [];
737 foreach my $translation ( @{$translations} ) {
738 next unless $translation->{translation};
739 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
740 $self->part->add_translations( $translation );
744 sub parse_form_makemodels {
748 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
749 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
752 $self->part->makemodels([]);
755 my $makemodels = delete($::form->{makemodels}) || [];
756 foreach my $makemodel ( @{$makemodels} ) {
757 next unless $makemodel->{make};
759 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
761 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
762 id => $makemodel->{id},
763 make => $makemodel->{make},
764 model => $makemodel->{model} || '',
765 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
766 sortorder => $position,
768 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
769 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
770 # don't change lastupdate
771 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
772 # new makemodel, no lastcost entered, leave lastupdate empty
773 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
774 # lastcost hasn't changed, use original lastupdate
775 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
777 $mm->lastupdate(DateTime->now);
779 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
780 $self->part->add_makemodels($mm);
784 sub build_bin_select {
785 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
786 title_key => 'description',
787 default => $_[0]->bin->id,
791 # get_set_inits for partpicker
794 if ($::form->{no_paginate}) {
795 $_[0]->models->disable_plugin('paginated');
801 # get_set_inits for part controller
805 # used by edit, save, delete and add
807 if ( $::form->{part}{id} ) {
808 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup shop_parts shop_parts.shop) ]);
810 die "part_type missing" unless $::form->{part}{part_type};
811 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
817 return $self->part->orphaned;
823 SL::Controller::Helper::GetModels->new(
830 partnumber => t8('Partnumber'),
831 description => t8('Description'),
833 with_objects => [ qw(unit_obj classification) ],
842 sub init_assortment_items {
843 # this init is used while saving and whenever assortments change dynamically
847 my $assortment_items = delete($::form->{assortment_items}) || [];
848 foreach my $assortment_item ( @{$assortment_items} ) {
849 next unless $assortment_item->{parts_id};
851 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
852 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
853 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
854 charge => $assortment_item->{charge},
855 unit => $assortment_item->{unit} || $part->unit,
856 position => $position,
864 sub init_makemodels {
868 my @makemodel_array = ();
869 my $makemodels = delete($::form->{makemodels}) || [];
871 foreach my $makemodel ( @{$makemodels} ) {
872 next unless $makemodel->{make};
874 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
875 id => $makemodel->{id},
876 make => $makemodel->{make},
877 model => $makemodel->{model} || '',
878 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
879 sortorder => $position,
880 ) or die "Can't create mm";
881 # $mm->id($makemodel->{id}) if $makemodel->{id};
882 push(@makemodel_array, $mm);
884 return \@makemodel_array;
887 sub init_assembly_items {
891 my $assembly_items = delete($::form->{assembly_items}) || [];
892 foreach my $assembly_item ( @{$assembly_items} ) {
893 next unless $assembly_item->{parts_id};
895 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
896 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
897 bom => $assembly_item->{bom},
898 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
899 position => $position,
906 sub init_all_warehouses {
908 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
911 sub init_all_languages {
912 SL::DB::Manager::Language->get_all_sorted;
915 sub init_all_partsgroups {
917 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
920 sub init_all_buchungsgruppen {
922 if ( $self->part->orphaned ) {
923 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
925 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
929 sub init_shops_not_assigned {
932 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
933 if ( @used_shop_ids ) {
934 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
937 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
943 if ( $self->part->orphaned ) {
944 return SL::DB::Manager::Unit->get_all_sorted;
946 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
950 sub init_all_payment_terms {
952 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
955 sub init_all_price_factors {
956 SL::DB::Manager::PriceFactor->get_all_sorted;
959 sub init_all_pricegroups {
960 SL::DB::Manager::Pricegroup->get_all_sorted;
963 # model used to filter/display the parts in the multi-items dialog
964 sub init_multi_items_models {
965 SL::Controller::Helper::GetModels->new(
968 with_objects => [ qw(unit_obj partsgroup classification) ],
969 disable_plugin => 'paginated',
970 source => $::form->{multi_items},
976 partnumber => t8('Partnumber'),
977 description => t8('Description')}
981 sub init_parts_classification_filter {
982 return [] unless $::form->{parts_classification_type};
984 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
985 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
987 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
990 # simple checks to run on $::form before saving
992 sub form_check_part_description_exists {
995 return 1 if $::form->{part}{description};
997 $self->js->flash('error', t8('Part Description missing!'))
998 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
999 ->focus('#part_description');
1003 sub form_check_assortment_items_exist {
1006 return 1 unless $::form->{part}{part_type} eq 'assortment';
1007 # skip item check for existing assortments that have been used
1008 return 1 if ($self->part->id and !$self->part->orphaned);
1010 # new or orphaned parts must have items in $::form->{assortment_items}
1011 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1012 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1013 ->focus('#add_assortment_item_name')
1014 ->flash('error', t8('The assortment doesn\'t have any items.'));
1020 sub form_check_assortment_items_unique {
1023 return 1 unless $::form->{part}{part_type} eq 'assortment';
1025 my %duplicate_elements;
1027 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1028 $duplicate_elements{$_}++ if $count{$_}++;
1031 if ( keys %duplicate_elements ) {
1032 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1033 ->flash('error', t8('There are duplicate assortment items'));
1039 sub form_check_assembly_items_exist {
1042 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1044 # skip item check for existing assembly that have been used
1045 return 1 if ($self->part->id and !$self->part->orphaned);
1047 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1048 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1049 ->focus('#add_assembly_item_name')
1050 ->flash('error', t8('The assembly doesn\'t have any items.'));
1056 sub form_check_partnumber_is_unique {
1059 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1060 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1062 $self->js->flash('error', t8('The partnumber already exists!'))
1063 ->focus('#part_description');
1070 # general checking functions
1073 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1079 $self->form_check_part_description_exists || return 0;
1080 $self->form_check_assortment_items_exist || return 0;
1081 $self->form_check_assortment_items_unique || return 0;
1082 $self->form_check_assembly_items_exist || return 0;
1083 $self->form_check_partnumber_is_unique || return 0;
1088 sub check_has_valid_part_type {
1089 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1092 sub render_assortment_items_to_html {
1093 my ($self, $assortment_items, $number_of_items) = @_;
1095 my $position = $number_of_items + 1;
1097 foreach my $ai (@$assortment_items) {
1098 $html .= $self->p->render('part/_assortment_row',
1099 PART => $self->part,
1100 orphaned => $self->orphaned,
1102 listrow => $position % 2 ? 1 : 0,
1103 position => $position, # for legacy assemblies
1110 sub render_assembly_items_to_html {
1111 my ($self, $assembly_items, $number_of_items) = @_;
1113 my $position = $number_of_items + 1;
1115 foreach my $ai (@{$assembly_items}) {
1116 $html .= $self->p->render('part/_assembly_row',
1117 PART => $self->part,
1118 orphaned => $self->orphaned,
1120 listrow => $position % 2 ? 1 : 0,
1121 position => $position, # for legacy assemblies
1128 sub parse_add_items_to_objects {
1129 my ($self, %params) = @_;
1130 my $part_type = $params{part_type};
1131 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1132 my $position = $params{position} || 1;
1134 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1137 foreach my $item ( @add_items ) {
1138 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1140 if ( $part_type eq 'assortment' ) {
1141 $ai = SL::DB::AssortmentItem->new(part => $part,
1142 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1143 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1144 position => $position,
1145 ) or die "Can't create AssortmentItem from item";
1146 } elsif ( $part_type eq 'assembly' ) {
1147 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1148 # id => $self->assembly->id, # will be set on save
1149 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1150 bom => 0, # default when adding: no bom
1151 position => $position,
1154 die "part_type must be assortment or assembly";
1156 push(@item_objects, $ai);
1160 return \@item_objects;
1163 sub _setup_form_action_bar {
1166 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1168 for my $bar ($::request->layout->get('actionbar')) {
1173 call => [ 'kivi.Part.save' ],
1174 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1175 accesskey => 'enter',
1179 call => [ 'kivi.Part.use_as_new' ],
1180 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1181 : !$may_edit ? t8('You do not have the permissions to access this function.')
1184 ], # end of combobox "Save"
1188 call => [ 'kivi.Part.delete' ],
1189 confirm => t8('Do you really want to delete this object?'),
1190 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1191 : !$may_edit ? t8('You do not have the permissions to access this function.')
1192 : !$self->part->orphaned ? t8('This object has already been used.')
1200 call => [ 'kivi.Part.open_history_popup' ],
1201 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1202 : !$may_edit ? t8('You do not have the permissions to access this function.')
1217 SL::Controller::Part - Part CRUD controller
1221 Controller for adding/editing/saving/deleting parts.
1223 All the relations are loaded at once and saving the part, adding a history
1224 entry and saving CVars happens inside one transaction. When saving the old
1225 relations are deleted and written as new to the database.
1227 Relations for parts:
1235 =item assembly items
1237 =item assortment items
1245 There are 4 different part types:
1251 The "default" part type.
1253 inventory_accno_id is set.
1257 Services can't be stocked.
1259 inventory_accno_id isn't set.
1263 Assemblies consist of other parts, services, assemblies or assortments. They
1264 aren't meant to be bought, only sold. To add assemblies to stock you typically
1265 have to make them, which reduces the stock by its respective components. Once
1266 an assembly item has been created there is currently no way to "disassemble" it
1267 again. An assembly item can appear several times in one assembly. An assmbly is
1268 sold as one item with a defined sellprice and lastcost. If the component prices
1269 change the assortment price remains the same. The assembly items may be printed
1270 in a record if the item's "bom" is set.
1274 Similar to assembly, but each assortment item may only appear once per
1275 assortment. When selling an assortment the assortment items are added to the
1276 record together with the assortment, which is added with sellprice 0.
1278 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1279 determined by the sum of the current assortment item prices when the assortment
1280 is added to a record. This also means that price rules and customer discounts
1281 will be applied to the assortment items.
1283 Once the assortment items have been added they may be modified or deleted, just
1284 as if they had been added manually, the individual assortment items aren't
1285 linked to the assortment or the other assortment items in any way.
1293 =item C<action_add_part>
1295 =item C<action_add_service>
1297 =item C<action_add_assembly>
1299 =item C<action_add_assortment>
1301 =item C<action_add PART_TYPE>
1303 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1304 parameter part_type as an action. Example:
1306 controller.pl?action=Part/add&part_type=service
1308 =item C<action_add_from_record>
1310 When adding new items to records they can be created on the fly if the entered
1311 partnumber or description doesn't exist yet. After being asked what part type
1312 the new item should have the user is redirected to the correct edit page.
1314 Depending on whether the item was added from a sales or a purchase record, only
1315 the relevant part classifications should be selectable for new item, so this
1316 parameter is passed on via a hidden parts_classification_type in the new_item
1319 =item C<action_save>
1321 Saves the current part and then reloads the edit page for the part.
1323 =item C<action_use_as_new>
1325 Takes the information from the current part, plus any modifications made on the
1326 page, and creates a new edit page that is ready to be saved. The partnumber is
1327 set empty, so a new partnumber from the number range will be used if the user
1328 doesn't enter one manually.
1330 Unsaved changes to the original part aren't updated.
1332 The part type cannot be changed in this way.
1334 =item C<action_delete>
1336 Deletes the current part and then redirects to the main page, there is no
1339 The delete button only appears if the part is 'orphaned', according to
1340 SL::DB::Part orphaned.
1342 The part can't be deleted if it appears in invoices, orders, delivery orders,
1343 the inventory, or is part of an assembly or assortment.
1345 If the part is deleted its relations prices, makdemodel, assembly,
1346 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1348 Before this controller items that appeared in inventory didn't count as
1349 orphaned and could be deleted and the inventory entries were also deleted, this
1350 "feature" hasn't been implemented.
1352 =item C<action_edit part.id>
1354 Load and display a part for editing.
1356 controller.pl?action=Part/edit&part.id=12345
1358 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1362 =head1 BUTTON ACTIONS
1368 Opens a popup displaying all the history entries. Once a new history controller
1369 is written the button could link there instead, with the part already selected.
1377 =item C<action_update_item_totals>
1379 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1380 amount of an item changes. The sum of all sellprices and lastcosts is
1381 calculated and the totals updated. Uses C<recalc_item_totals>.
1383 =item C<action_add_assortment_item>
1385 Adds a new assortment item from a part picker seleciton to the assortment item list
1387 If the item already exists in the assortment the item isn't added and a Flash
1390 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1391 after adding each new item, add the new object to the item objects that were
1392 already parsed, calculate totals via a dummy part then update the row and the
1395 =item C<action_add_assembly_item>
1397 Adds a new assembly item from a part picker seleciton to the assembly item list
1399 If the item already exists in the assembly a flash info is generated, but the
1402 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1403 after adding each new item, add the new object to the item objects that were
1404 already parsed, calculate totals via a dummy part then update the row and the
1407 =item C<action_add_multi_assortment_items>
1409 Parses the items to be added from the form generated by the multi input and
1410 appends the html of the tr-rows to the assortment item table. Afterwards all
1411 assortment items are renumbered and the sums recalculated via
1412 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1414 =item C<action_add_multi_assembly_items>
1416 Parses the items to be added from the form generated by the multi input and
1417 appends the html of the tr-rows to the assembly item table. Afterwards all
1418 assembly items are renumbered and the sums recalculated via
1419 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1421 =item C<action_show_multi_items_dialog>
1423 =item C<action_multi_items_update_result>
1425 =item C<action_add_makemodel_row>
1427 Add a new makemodel row with the vendor that was selected via the vendor
1430 Checks the already existing makemodels and warns if a row with that vendor
1431 already exists. Currently it is possible to have duplicate vendor rows.
1433 =item C<action_reorder_items>
1435 Sorts the item table for assembly or assortment items.
1437 =item C<action_warehouse_changed>
1441 =head1 ACTIONS part picker
1445 =item C<action_ajax_autocomplete>
1447 =item C<action_test_page>
1449 =item C<action_part_picker_search>
1451 =item C<action_part_picker_result>
1453 =item C<action_show>
1463 Calls some simple checks that test the submitted $::form for obvious errors.
1464 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1466 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1467 some cases extra actions are taken, e.g. if the part description is missing the
1468 basic data tab is selected and the description input field is focussed.
1474 =item C<form_check_part_description_exists>
1476 =item C<form_check_assortment_items_exist>
1478 =item C<form_check_assortment_items_unique>
1480 =item C<form_check_assembly_items_exist>
1482 =item C<form_check_partnumber_is_unique>
1486 =head1 HELPER FUNCTIONS
1492 When submitting the form for saving, parses the transmitted form. Expects the
1496 $::form->{makemodels}
1497 $::form->{translations}
1499 $::form->{assemblies}
1500 $::form->{assortments}
1502 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1504 =item C<recalc_item_totals %params>
1506 Helper function for calculating the total lastcost and sellprice for assemblies
1507 or assortments according to their items, which are parsed from the current
1510 Is called whenever the qty of an item is changed or items are deleted.
1514 * part_type : 'assortment' or 'assembly' (mandatory)
1516 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1518 Depending on the price_type the lastcost sum or sellprice sum is returned.
1520 Doesn't work for recursive items.
1524 =head1 GET SET INITS
1526 There are get_set_inits for
1534 which parse $::form and automatically create an array of objects.
1536 These inits are used during saving and each time a new element is added.
1540 =item C<init_makemodels>
1542 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1543 $self->part->makemodels, ready to be saved.
1545 Used for saving parts and adding new makemodel rows.
1547 =item C<parse_add_items_to_objects PART_TYPE>
1549 Parses the resulting form from either the part-picker submit or the multi-item
1550 submit, and creates an arrayref of assortment_item or assembly objects, that
1551 can be rendered via C<render_assortment_items_to_html> or
1552 C<render_assembly_items_to_html>.
1554 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1555 Optional param: position (used for numbering and listrow class)
1557 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1559 Takes an array_ref of assortment_items, and generates tables rows ready for
1560 adding to the assortment table. Is used when a part is loaded, or whenever new
1561 assortment items are added.
1563 =item C<parse_form_makemodels>
1565 Makemodels can't just be overwritten, because of the field "lastupdate", that
1566 remembers when the lastcost for that vendor changed the last time.
1568 So the original values are cloned and remembered, so we can compare if lastcost
1569 was changed in $::form, and keep or update lastupdate.
1571 lastcost isn't updated until the first time it was saved with a value, until
1574 Also a boolean "makemodel" needs to be written in parts, depending on whether
1575 makemodel entries exist or not.
1577 We still need init_makemodels for when we open the part for editing.
1587 It should be possible to jump to the edit page in a specific tab
1591 Support callbacks, e.g. creating a new part from within an order, and jumping
1592 back to the order again afterwards.
1596 Support units when adding assembly items or assortment items. Currently the
1597 default unit of the item is always used.
1601 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1602 consists of other assemblies.
1608 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>