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);
23 use Rose::Object::MakeMethods::Generic (
24 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
25 makemodels shops_not_assigned
27 assortment assortment_items assembly assembly_items
28 all_pricegroups all_translations all_partsgroups all_units
29 all_buchungsgruppen all_payment_terms all_warehouses
30 parts_classification_filter
31 all_languages all_units all_price_factors) ],
32 'scalar' => [ qw(warehouse bin) ],
36 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
37 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
39 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
41 # actions for editing parts
44 my ($self, %params) = @_;
46 $self->part( SL::DB::Part->new_part );
50 sub action_add_service {
51 my ($self, %params) = @_;
53 $self->part( SL::DB::Part->new_service );
57 sub action_add_assembly {
58 my ($self, %params) = @_;
60 $self->part( SL::DB::Part->new_assembly );
64 sub action_add_assortment {
65 my ($self, %params) = @_;
67 $self->part( SL::DB::Part->new_assortment );
71 sub action_add_from_record {
74 check_has_valid_part_type($::form->{part}{part_type});
76 die 'parts_classification_type must be "sales" or "purchases"'
77 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
86 check_has_valid_part_type($::form->{part_type});
88 $self->action_add_part if $::form->{part_type} eq 'part';
89 $self->action_add_service if $::form->{part_type} eq 'service';
90 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
91 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
95 my ($self, %params) = @_;
97 # checks that depend only on submitted $::form
98 $self->check_form or return $self->js->render;
100 my $is_new = !$self->part->id; # $ part gets loaded here
102 # check that the part hasn't been modified
104 $self->check_part_not_modified or
105 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;
109 && $::form->{part}{partnumber}
110 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
112 return $self->js->error(t8('The partnumber is already being used'))->render;
117 my @errors = $self->part->validate;
118 return $self->js->error(@errors)->render if @errors;
120 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
121 $self->part->db->with_transaction(sub {
123 if ( $params{save_as_new} ) {
124 $self->part( $self->part->clone_and_reset_deep );
125 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
128 $self->part->save(cascade => 1);
130 SL::DB::History->new(
131 trans_id => $self->part->id,
132 snumbers => 'partnumber_' . $self->part->partnumber,
133 employee_id => SL::DB::Manager::Employee->current->id,
138 CVar->save_custom_variables(
139 dbh => $self->part->db->dbh,
141 trans_id => $self->part->id,
142 variables => $::form, # $::form->{cvar} would be nicer
147 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
150 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
152 if ( $::form->{callback} ) {
153 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
156 # default behaviour after save: reload item, this also resets last_modification!
157 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
161 sub action_save_as_new {
163 $self->action_save(save_as_new=>1);
169 my $db = $self->part->db; # $self->part has a get_set_init on $::form
171 my $partnumber = $self->part->partnumber; # remember for history log
176 # delete part, together with relationships that don't already
177 # have an ON DELETE CASCADE, e.g. makemodel and translation.
178 $self->part->delete(cascade => 1);
180 SL::DB::History->new(
181 trans_id => $self->part->id,
182 snumbers => 'partnumber_' . $partnumber,
183 employee_id => SL::DB::Manager::Employee->current->id,
185 addition => 'DELETED',
188 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
190 flash_later('info', t8('The item has been deleted.'));
191 if ( $::form->{callback} ) {
192 $self->redirect_to($::form->unescape($::form->{callback}));
194 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
198 sub action_use_as_new {
199 my ($self, %params) = @_;
201 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
202 $::form->{oldpartnumber} = $oldpart->partnumber;
204 $self->part($oldpart->clone_and_reset_deep);
206 $self->part->partnumber(undef);
212 my ($self, %params) = @_;
218 my ($self, %params) = @_;
220 $self->_set_javascript;
221 $self->_setup_form_action_bar;
223 my (%assortment_vars, %assembly_vars);
224 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
225 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
227 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
229 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
230 if (scalar @{ $params{CUSTOM_VARIABLES} });
232 my %title_hash = ( part => t8('Edit Part'),
233 assembly => t8('Edit Assembly'),
234 service => t8('Edit Service'),
235 assortment => t8('Edit Assortment'),
238 $self->part->prices([]) unless $self->part->prices;
239 $self->part->translations([]) unless $self->part->translations;
243 title => $title_hash{$self->part->part_type},
246 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
247 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
248 oldpartnumber => $::form->{oldpartnumber},
249 old_id => $::form->{old_id},
257 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
258 $_[0]->render('part/history', { layout => 0 },
259 history_entries => $history_entries);
262 sub action_update_item_totals {
265 my $part_type = $::form->{part_type};
266 die unless $part_type =~ /^(assortment|assembly)$/;
268 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
269 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
271 my $sum_diff = $sellprice_sum-$lastcost_sum;
274 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
275 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
276 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
277 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
278 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
279 ->no_flash_clear->render();
282 sub action_add_multi_assortment_items {
285 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
286 my $html = $self->render_assortment_items_to_html($item_objects);
288 $self->js->run('kivi.Part.close_picker_dialogs')
289 ->append('#assortment_rows', $html)
290 ->run('kivi.Part.renumber_positions')
291 ->run('kivi.Part.assortment_recalc')
295 sub action_add_multi_assembly_items {
298 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
300 foreach my $item (@{$item_objects}) {
301 my $errstr = validate_assembly($item->part,$self->part);
302 $self->js->flash('error',$errstr) if $errstr;
303 push (@checked_objects,$item) unless $errstr;
306 my $html = $self->render_assembly_items_to_html(\@checked_objects);
308 $self->js->run('kivi.Part.close_picker_dialogs')
309 ->append('#assembly_rows', $html)
310 ->run('kivi.Part.renumber_positions')
311 ->run('kivi.Part.assembly_recalc')
315 sub action_add_assortment_item {
316 my ($self, %params) = @_;
318 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
320 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
322 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
323 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
324 return $self->js->flash('error', t8("This part has already been added."))->render;
327 my $number_of_items = scalar @{$self->assortment_items};
328 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
329 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
331 push(@{$self->assortment_items}, @{$item_objects});
332 my $part = SL::DB::Part->new(part_type => 'assortment');
333 $part->assortment_items(@{$self->assortment_items});
334 my $items_sellprice_sum = $part->items_sellprice_sum;
335 my $items_lastcost_sum = $part->items_lastcost_sum;
336 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
339 ->append('#assortment_rows' , $html) # append in tbody
340 ->val('.add_assortment_item_input' , '')
341 ->run('kivi.Part.focus_last_assortment_input')
342 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
343 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
344 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
345 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
346 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
350 sub action_add_assembly_item {
353 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
355 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
357 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
359 my $duplicate_warning = 0; # duplicates are allowed, just warn
360 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
361 $duplicate_warning++;
364 my $number_of_items = scalar @{$self->assembly_items};
365 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
367 foreach my $item (@{$item_objects}) {
368 my $errstr = validate_assembly($item->part,$self->part);
369 return $self->js->flash('error',$errstr)->render if $errstr;
374 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
376 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
378 push(@{$self->assembly_items}, @{$item_objects});
379 my $part = SL::DB::Part->new(part_type => 'assembly');
380 $part->assemblies(@{$self->assembly_items});
381 my $items_sellprice_sum = $part->items_sellprice_sum;
382 my $items_lastcost_sum = $part->items_lastcost_sum;
383 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
386 ->append('#assembly_rows', $html) # append in tbody
387 ->val('.add_assembly_item_input' , '')
388 ->run('kivi.Part.focus_last_assembly_input')
389 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
390 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
391 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
392 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
393 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
397 sub action_show_multi_items_dialog {
398 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
399 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
403 sub action_multi_items_update_result {
406 $::form->{multi_items}->{filter}->{obsolete} = 0;
408 my $count = $_[0]->multi_items_models->count;
411 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
412 $_[0]->render($text, { layout => 0 });
413 } elsif ($count > $max_count) {
414 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
415 $_[0]->render($text, { layout => 0 });
417 my $multi_items = $_[0]->multi_items_models->get;
418 $_[0]->render('part/_multi_items_result', { layout => 0 },
419 multi_items => $multi_items);
423 sub action_add_makemodel_row {
426 my $vendor_id = $::form->{add_makemodel};
428 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
429 return $self->js->error(t8("No vendor selected or found!"))->render;
431 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
432 $self->js->flash('info', t8("This vendor has already been added."));
435 my $position = scalar @{$self->makemodels} + 1;
437 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
441 sortorder => $position,
442 ) or die "Can't create MakeModel object";
444 my $row_as_html = $self->p->render('part/_makemodel_row',
446 listrow => $position % 2 ? 0 : 1,
449 # after selection focus on the model field in the row that was just added
451 ->append('#makemodel_rows', $row_as_html) # append in tbody
452 ->val('.add_makemodel_input', '')
453 ->run('kivi.Part.focus_last_makemodel_input')
457 sub action_reorder_items {
460 my $part_type = $::form->{part_type};
463 partnumber => sub { $_[0]->part->partnumber },
464 description => sub { $_[0]->part->description },
465 qty => sub { $_[0]->qty },
466 sellprice => sub { $_[0]->part->sellprice },
467 lastcost => sub { $_[0]->part->lastcost },
468 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
471 my $method = $sort_keys{$::form->{order_by}};
474 if ($part_type eq 'assortment') {
475 @items = @{ $self->assortment_items };
477 @items = @{ $self->assembly_items };
480 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
481 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
482 if ($::form->{sort_dir}) {
483 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
485 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
488 if ($::form->{sort_dir}) {
489 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
491 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
495 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
498 sub action_warehouse_changed {
501 if ($::form->{warehouse_id} ) {
502 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
503 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
505 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
506 $self->bin($self->warehouse->bins->[0]);
508 ->html('#bin', $self->build_bin_select)
509 ->focus('#part_bin_id');
510 return $self->js->render;
514 # no warehouse was selected, empty the bin field and reset the id
516 ->val('#part_bin_id', undef)
519 return $self->js->render;
522 sub action_ajax_autocomplete {
523 my ($self, %params) = @_;
525 # if someone types something, and hits enter, assume he entered the full name.
526 # if something matches, treat that as sole match
527 # since we need a second get models instance with different filters for that,
528 # we only modify the original filter temporarily in place
529 if ($::form->{prefer_exact}) {
530 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
532 my $exact_models = SL::Controller::Helper::GetModels->new(
535 paginated => { per_page => 2 },
536 with_objects => [ qw(unit_obj classification) ],
539 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
540 $self->parts($exact_matches);
546 value => $_->displayable_name,
547 label => $_->displayable_name,
549 partnumber => $_->partnumber,
550 description => $_->description,
551 part_type => $_->part_type,
553 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
555 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
557 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
560 sub action_test_page {
561 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
564 sub action_part_picker_search {
565 $_[0]->render('part/part_picker_search', { layout => 0 });
568 sub action_part_picker_result {
569 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
575 if ($::request->type eq 'json') {
580 $part_hash = $self->part->as_tree;
581 $part_hash->{cvars} = $self->part->cvar_as_hashref;
584 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
589 sub validate_add_items {
590 scalar @{$::form->{add_items}};
593 sub prepare_assortment_render_vars {
596 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
597 items_lastcost_sum => $self->part->items_lastcost_sum,
598 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
600 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
605 sub prepare_assembly_render_vars {
608 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
609 items_lastcost_sum => $self->part->items_lastcost_sum,
610 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
612 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
620 check_has_valid_part_type($self->part->part_type);
622 $self->_set_javascript;
623 $self->_setup_form_action_bar;
625 my %title_hash = ( part => t8('Add Part'),
626 assembly => t8('Add Assembly'),
627 service => t8('Add Service'),
628 assortment => t8('Add Assortment'),
633 title => $title_hash{$self->part->part_type},
638 sub _set_javascript {
640 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
641 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
644 sub recalc_item_totals {
645 my ($self, %params) = @_;
647 if ( $params{part_type} eq 'assortment' ) {
648 return 0 unless scalar @{$self->assortment_items};
649 } elsif ( $params{part_type} eq 'assembly' ) {
650 return 0 unless scalar @{$self->assembly_items};
652 carp "can only calculate sum for assortments and assemblies";
655 my $part = SL::DB::Part->new(part_type => $params{part_type});
656 if ( $part->is_assortment ) {
657 $part->assortment_items( @{$self->assortment_items} );
658 if ( $params{price_type} eq 'lastcost' ) {
659 return $part->items_lastcost_sum;
661 if ( $params{pricegroup_id} ) {
662 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
664 return $part->items_sellprice_sum;
667 } elsif ( $part->is_assembly ) {
668 $part->assemblies( @{$self->assembly_items} );
669 if ( $params{price_type} eq 'lastcost' ) {
670 return $part->items_lastcost_sum;
672 return $part->items_sellprice_sum;
677 sub check_part_not_modified {
680 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
687 my $is_new = !$self->part->id;
689 my $params = delete($::form->{part}) || { };
691 delete $params->{id};
692 $self->part->assign_attributes(%{ $params});
693 $self->part->bin_id(undef) unless $self->part->warehouse_id;
695 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
696 # will be the case for used assortments when saving, or when a used assortment
698 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
699 $self->part->assortment_items([]);
700 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
703 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
704 $self->part->assemblies([]); # completely rewrite assortments each time
705 $self->part->add_assemblies( @{ $self->assembly_items } );
708 $self->part->translations([]);
709 $self->parse_form_translations;
711 $self->part->prices([]);
712 $self->parse_form_prices;
714 $self->parse_form_makemodels;
717 sub parse_form_prices {
719 # only save prices > 0
720 my $prices = delete($::form->{prices}) || [];
721 foreach my $price ( @{$prices} ) {
722 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
723 next unless $sellprice > 0; # skip negative prices as well
724 my $p = SL::DB::Price->new(parts_id => $self->part->id,
725 pricegroup_id => $price->{pricegroup_id},
728 $self->part->add_prices($p);
732 sub parse_form_translations {
734 # don't add empty translations
735 my $translations = delete($::form->{translations}) || [];
736 foreach my $translation ( @{$translations} ) {
737 next unless $translation->{translation};
738 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
739 $self->part->add_translations( $translation );
743 sub parse_form_makemodels {
747 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
748 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
751 $self->part->makemodels([]);
754 my $makemodels = delete($::form->{makemodels}) || [];
755 foreach my $makemodel ( @{$makemodels} ) {
756 next unless $makemodel->{make};
758 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
760 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
761 id => $makemodel->{id},
762 make => $makemodel->{make},
763 model => $makemodel->{model} || '',
764 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
765 sortorder => $position,
767 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
768 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
769 # don't change lastupdate
770 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
771 # new makemodel, no lastcost entered, leave lastupdate empty
772 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
773 # lastcost hasn't changed, use original lastupdate
774 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
776 $mm->lastupdate(DateTime->now);
778 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
779 $self->part->add_makemodels($mm);
783 sub build_bin_select {
784 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
785 title_key => 'description',
786 default => $_[0]->bin->id,
790 # get_set_inits for partpicker
793 if ($::form->{no_paginate}) {
794 $_[0]->models->disable_plugin('paginated');
800 # get_set_inits for part controller
804 # used by edit, save, delete and add
806 if ( $::form->{part}{id} ) {
807 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup shop_parts shop_parts.shop) ]);
809 die "part_type missing" unless $::form->{part}{part_type};
810 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
816 return $self->part->orphaned;
822 SL::Controller::Helper::GetModels->new(
829 partnumber => t8('Partnumber'),
830 description => t8('Description'),
832 with_objects => [ qw(unit_obj classification) ],
841 sub init_assortment_items {
842 # this init is used while saving and whenever assortments change dynamically
846 my $assortment_items = delete($::form->{assortment_items}) || [];
847 foreach my $assortment_item ( @{$assortment_items} ) {
848 next unless $assortment_item->{parts_id};
850 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
851 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
852 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
853 charge => $assortment_item->{charge},
854 unit => $assortment_item->{unit} || $part->unit,
855 position => $position,
863 sub init_makemodels {
867 my @makemodel_array = ();
868 my $makemodels = delete($::form->{makemodels}) || [];
870 foreach my $makemodel ( @{$makemodels} ) {
871 next unless $makemodel->{make};
873 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
874 id => $makemodel->{id},
875 make => $makemodel->{make},
876 model => $makemodel->{model} || '',
877 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
878 sortorder => $position,
879 ) or die "Can't create mm";
880 # $mm->id($makemodel->{id}) if $makemodel->{id};
881 push(@makemodel_array, $mm);
883 return \@makemodel_array;
886 sub init_assembly_items {
890 my $assembly_items = delete($::form->{assembly_items}) || [];
891 foreach my $assembly_item ( @{$assembly_items} ) {
892 next unless $assembly_item->{parts_id};
894 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
895 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
896 bom => $assembly_item->{bom},
897 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
898 position => $position,
905 sub init_all_warehouses {
907 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
910 sub init_all_languages {
911 SL::DB::Manager::Language->get_all_sorted;
914 sub init_all_partsgroups {
916 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
919 sub init_all_buchungsgruppen {
921 if ( $self->part->orphaned ) {
922 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
924 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
928 sub init_shops_not_assigned {
931 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
932 if ( @used_shop_ids ) {
933 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
936 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
942 if ( $self->part->orphaned ) {
943 return SL::DB::Manager::Unit->get_all_sorted;
945 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
949 sub init_all_payment_terms {
951 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
954 sub init_all_price_factors {
955 SL::DB::Manager::PriceFactor->get_all_sorted;
958 sub init_all_pricegroups {
959 SL::DB::Manager::Pricegroup->get_all_sorted;
962 # model used to filter/display the parts in the multi-items dialog
963 sub init_multi_items_models {
964 SL::Controller::Helper::GetModels->new(
967 with_objects => [ qw(unit_obj partsgroup classification) ],
968 disable_plugin => 'paginated',
969 source => $::form->{multi_items},
975 partnumber => t8('Partnumber'),
976 description => t8('Description')}
980 sub init_parts_classification_filter {
981 return [] unless $::form->{parts_classification_type};
983 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
984 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
986 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
989 # simple checks to run on $::form before saving
991 sub form_check_part_description_exists {
994 return 1 if $::form->{part}{description};
996 $self->js->flash('error', t8('Part Description missing!'))
997 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
998 ->focus('#part_description');
1002 sub form_check_assortment_items_exist {
1005 return 1 unless $::form->{part}{part_type} eq 'assortment';
1006 # skip item check for existing assortments that have been used
1007 return 1 if ($self->part->id and !$self->part->orphaned);
1009 # new or orphaned parts must have items in $::form->{assortment_items}
1010 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1011 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1012 ->focus('#add_assortment_item_name')
1013 ->flash('error', t8('The assortment doesn\'t have any items.'));
1019 sub form_check_assortment_items_unique {
1022 return 1 unless $::form->{part}{part_type} eq 'assortment';
1024 my %duplicate_elements;
1026 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1027 $duplicate_elements{$_}++ if $count{$_}++;
1030 if ( keys %duplicate_elements ) {
1031 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1032 ->flash('error', t8('There are duplicate assortment items'));
1038 sub form_check_assembly_items_exist {
1041 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1043 # skip item check for existing assembly that have been used
1044 return 1 if ($self->part->id and !$self->part->orphaned);
1046 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1047 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1048 ->focus('#add_assembly_item_name')
1049 ->flash('error', t8('The assembly doesn\'t have any items.'));
1055 sub form_check_partnumber_is_unique {
1058 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1059 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1061 $self->js->flash('error', t8('The partnumber already exists!'))
1062 ->focus('#part_description');
1069 # general checking functions
1072 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1078 $self->form_check_part_description_exists || return 0;
1079 $self->form_check_assortment_items_exist || return 0;
1080 $self->form_check_assortment_items_unique || return 0;
1081 $self->form_check_assembly_items_exist || return 0;
1082 $self->form_check_partnumber_is_unique || return 0;
1087 sub check_has_valid_part_type {
1088 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1091 sub render_assortment_items_to_html {
1092 my ($self, $assortment_items, $number_of_items) = @_;
1094 my $position = $number_of_items + 1;
1096 foreach my $ai (@$assortment_items) {
1097 $html .= $self->p->render('part/_assortment_row',
1098 PART => $self->part,
1099 orphaned => $self->orphaned,
1101 listrow => $position % 2 ? 1 : 0,
1102 position => $position, # for legacy assemblies
1109 sub render_assembly_items_to_html {
1110 my ($self, $assembly_items, $number_of_items) = @_;
1112 my $position = $number_of_items + 1;
1114 foreach my $ai (@{$assembly_items}) {
1115 $html .= $self->p->render('part/_assembly_row',
1116 PART => $self->part,
1117 orphaned => $self->orphaned,
1119 listrow => $position % 2 ? 1 : 0,
1120 position => $position, # for legacy assemblies
1127 sub parse_add_items_to_objects {
1128 my ($self, %params) = @_;
1129 my $part_type = $params{part_type};
1130 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1131 my $position = $params{position} || 1;
1133 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1136 foreach my $item ( @add_items ) {
1137 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1139 if ( $part_type eq 'assortment' ) {
1140 $ai = SL::DB::AssortmentItem->new(part => $part,
1141 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1142 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1143 position => $position,
1144 ) or die "Can't create AssortmentItem from item";
1145 } elsif ( $part_type eq 'assembly' ) {
1146 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1147 # id => $self->assembly->id, # will be set on save
1148 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1149 bom => 0, # default when adding: no bom
1150 position => $position,
1153 die "part_type must be assortment or assembly";
1155 push(@item_objects, $ai);
1159 return \@item_objects;
1162 sub _setup_form_action_bar {
1165 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1167 for my $bar ($::request->layout->get('actionbar')) {
1172 call => [ 'kivi.Part.save' ],
1173 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1174 accesskey => 'enter',
1178 call => [ 'kivi.Part.use_as_new' ],
1179 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1180 : !$may_edit ? t8('You do not have the permissions to access this function.')
1183 ], # end of combobox "Save"
1187 call => [ 'kivi.Part.delete' ],
1188 confirm => t8('Do you really want to delete this object?'),
1189 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1190 : !$may_edit ? t8('You do not have the permissions to access this function.')
1191 : !$self->part->orphaned ? t8('This object has already been used.')
1199 call => [ 'kivi.Part.open_history_popup' ],
1200 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1201 : !$may_edit ? t8('You do not have the permissions to access this function.')
1216 SL::Controller::Part - Part CRUD controller
1220 Controller for adding/editing/saving/deleting parts.
1222 All the relations are loaded at once and saving the part, adding a history
1223 entry and saving CVars happens inside one transaction. When saving the old
1224 relations are deleted and written as new to the database.
1226 Relations for parts:
1234 =item assembly items
1236 =item assortment items
1244 There are 4 different part types:
1250 The "default" part type.
1252 inventory_accno_id is set.
1256 Services can't be stocked.
1258 inventory_accno_id isn't set.
1262 Assemblies consist of other parts, services, assemblies or assortments. They
1263 aren't meant to be bought, only sold. To add assemblies to stock you typically
1264 have to make them, which reduces the stock by its respective components. Once
1265 an assembly item has been created there is currently no way to "disassemble" it
1266 again. An assembly item can appear several times in one assembly. An assmbly is
1267 sold as one item with a defined sellprice and lastcost. If the component prices
1268 change the assortment price remains the same. The assembly items may be printed
1269 in a record if the item's "bom" is set.
1273 Similar to assembly, but each assortment item may only appear once per
1274 assortment. When selling an assortment the assortment items are added to the
1275 record together with the assortment, which is added with sellprice 0.
1277 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1278 determined by the sum of the current assortment item prices when the assortment
1279 is added to a record. This also means that price rules and customer discounts
1280 will be applied to the assortment items.
1282 Once the assortment items have been added they may be modified or deleted, just
1283 as if they had been added manually, the individual assortment items aren't
1284 linked to the assortment or the other assortment items in any way.
1292 =item C<action_add_part>
1294 =item C<action_add_service>
1296 =item C<action_add_assembly>
1298 =item C<action_add_assortment>
1300 =item C<action_add PART_TYPE>
1302 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1303 parameter part_type as an action. Example:
1305 controller.pl?action=Part/add&part_type=service
1307 =item C<action_add_from_record>
1309 When adding new items to records they can be created on the fly if the entered
1310 partnumber or description doesn't exist yet. After being asked what part type
1311 the new item should have the user is redirected to the correct edit page.
1313 Depending on whether the item was added from a sales or a purchase record, only
1314 the relevant part classifications should be selectable for new item, so this
1315 parameter is passed on via a hidden parts_classification_type in the new_item
1318 =item C<action_save>
1320 Saves the current part and then reloads the edit page for the part.
1322 =item C<action_use_as_new>
1324 Takes the information from the current part, plus any modifications made on the
1325 page, and creates a new edit page that is ready to be saved. The partnumber is
1326 set empty, so a new partnumber from the number range will be used if the user
1327 doesn't enter one manually.
1329 Unsaved changes to the original part aren't updated.
1331 The part type cannot be changed in this way.
1333 =item C<action_delete>
1335 Deletes the current part and then redirects to the main page, there is no
1338 The delete button only appears if the part is 'orphaned', according to
1339 SL::DB::Part orphaned.
1341 The part can't be deleted if it appears in invoices, orders, delivery orders,
1342 the inventory, or is part of an assembly or assortment.
1344 If the part is deleted its relations prices, makdemodel, assembly,
1345 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1347 Before this controller items that appeared in inventory didn't count as
1348 orphaned and could be deleted and the inventory entries were also deleted, this
1349 "feature" hasn't been implemented.
1351 =item C<action_edit part.id>
1353 Load and display a part for editing.
1355 controller.pl?action=Part/edit&part.id=12345
1357 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1361 =head1 BUTTON ACTIONS
1367 Opens a popup displaying all the history entries. Once a new history controller
1368 is written the button could link there instead, with the part already selected.
1376 =item C<action_update_item_totals>
1378 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1379 amount of an item changes. The sum of all sellprices and lastcosts is
1380 calculated and the totals updated. Uses C<recalc_item_totals>.
1382 =item C<action_add_assortment_item>
1384 Adds a new assortment item from a part picker seleciton to the assortment item list
1386 If the item already exists in the assortment the item isn't added and a Flash
1389 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1390 after adding each new item, add the new object to the item objects that were
1391 already parsed, calculate totals via a dummy part then update the row and the
1394 =item C<action_add_assembly_item>
1396 Adds a new assembly item from a part picker seleciton to the assembly item list
1398 If the item already exists in the assembly a flash info is generated, but the
1401 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1402 after adding each new item, add the new object to the item objects that were
1403 already parsed, calculate totals via a dummy part then update the row and the
1406 =item C<action_add_multi_assortment_items>
1408 Parses the items to be added from the form generated by the multi input and
1409 appends the html of the tr-rows to the assortment item table. Afterwards all
1410 assortment items are renumbered and the sums recalculated via
1411 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1413 =item C<action_add_multi_assembly_items>
1415 Parses the items to be added from the form generated by the multi input and
1416 appends the html of the tr-rows to the assembly item table. Afterwards all
1417 assembly items are renumbered and the sums recalculated via
1418 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1420 =item C<action_show_multi_items_dialog>
1422 =item C<action_multi_items_update_result>
1424 =item C<action_add_makemodel_row>
1426 Add a new makemodel row with the vendor that was selected via the vendor
1429 Checks the already existing makemodels and warns if a row with that vendor
1430 already exists. Currently it is possible to have duplicate vendor rows.
1432 =item C<action_reorder_items>
1434 Sorts the item table for assembly or assortment items.
1436 =item C<action_warehouse_changed>
1440 =head1 ACTIONS part picker
1444 =item C<action_ajax_autocomplete>
1446 =item C<action_test_page>
1448 =item C<action_part_picker_search>
1450 =item C<action_part_picker_result>
1452 =item C<action_show>
1462 Calls some simple checks that test the submitted $::form for obvious errors.
1463 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1465 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1466 some cases extra actions are taken, e.g. if the part description is missing the
1467 basic data tab is selected and the description input field is focussed.
1473 =item C<form_check_part_description_exists>
1475 =item C<form_check_assortment_items_exist>
1477 =item C<form_check_assortment_items_unique>
1479 =item C<form_check_assembly_items_exist>
1481 =item C<form_check_partnumber_is_unique>
1485 =head1 HELPER FUNCTIONS
1491 When submitting the form for saving, parses the transmitted form. Expects the
1495 $::form->{makemodels}
1496 $::form->{translations}
1498 $::form->{assemblies}
1499 $::form->{assortments}
1501 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1503 =item C<recalc_item_totals %params>
1505 Helper function for calculating the total lastcost and sellprice for assemblies
1506 or assortments according to their items, which are parsed from the current
1509 Is called whenever the qty of an item is changed or items are deleted.
1513 * part_type : 'assortment' or 'assembly' (mandatory)
1515 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1517 Depending on the price_type the lastcost sum or sellprice sum is returned.
1519 Doesn't work for recursive items.
1523 =head1 GET SET INITS
1525 There are get_set_inits for
1533 which parse $::form and automatically create an array of objects.
1535 These inits are used during saving and each time a new element is added.
1539 =item C<init_makemodels>
1541 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1542 $self->part->makemodels, ready to be saved.
1544 Used for saving parts and adding new makemodel rows.
1546 =item C<parse_add_items_to_objects PART_TYPE>
1548 Parses the resulting form from either the part-picker submit or the multi-item
1549 submit, and creates an arrayref of assortment_item or assembly objects, that
1550 can be rendered via C<render_assortment_items_to_html> or
1551 C<render_assembly_items_to_html>.
1553 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1554 Optional param: position (used for numbering and listrow class)
1556 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1558 Takes an array_ref of assortment_items, and generates tables rows ready for
1559 adding to the assortment table. Is used when a part is loaded, or whenever new
1560 assortment items are added.
1562 =item C<parse_form_makemodels>
1564 Makemodels can't just be overwritten, because of the field "lastupdate", that
1565 remembers when the lastcost for that vendor changed the last time.
1567 So the original values are cloned and remembered, so we can compare if lastcost
1568 was changed in $::form, and keep or update lastupdate.
1570 lastcost isn't updated until the first time it was saved with a value, until
1573 Also a boolean "makemodel" needs to be written in parts, depending on whether
1574 makemodel entries exist or not.
1576 We still need init_makemodels for when we open the part for editing.
1586 It should be possible to jump to the edit page in a specific tab
1590 Support callbacks, e.g. creating a new part from within an order, and jumping
1591 back to the order again afterwards.
1595 Support units when adding assembly items or assortment items. Currently the
1596 default unit of the item is always used.
1600 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1601 consists of other assemblies.
1607 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>