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,
553 part_type => $_->part_type,
555 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
557 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
559 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
562 sub action_test_page {
563 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
566 sub action_part_picker_search {
567 $_[0]->render('part/part_picker_search', { layout => 0 });
570 sub action_part_picker_result {
571 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
577 if ($::request->type eq 'json') {
582 $part_hash = $self->part->as_tree;
583 $part_hash->{cvars} = $self->part->cvar_as_hashref;
586 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
591 sub validate_add_items {
592 scalar @{$::form->{add_items}};
595 sub prepare_assortment_render_vars {
598 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
599 items_lastcost_sum => $self->part->items_lastcost_sum,
600 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
602 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
607 sub prepare_assembly_render_vars {
610 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
611 items_lastcost_sum => $self->part->items_lastcost_sum,
612 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
614 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
622 check_has_valid_part_type($self->part->part_type);
624 $self->_set_javascript;
625 $self->_setup_form_action_bar;
627 my %title_hash = ( part => t8('Add Part'),
628 assembly => t8('Add Assembly'),
629 service => t8('Add Service'),
630 assortment => t8('Add Assortment'),
635 title => $title_hash{$self->part->part_type},
640 sub _set_javascript {
642 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
643 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
646 sub recalc_item_totals {
647 my ($self, %params) = @_;
649 if ( $params{part_type} eq 'assortment' ) {
650 return 0 unless scalar @{$self->assortment_items};
651 } elsif ( $params{part_type} eq 'assembly' ) {
652 return 0 unless scalar @{$self->assembly_items};
654 carp "can only calculate sum for assortments and assemblies";
657 my $part = SL::DB::Part->new(part_type => $params{part_type});
658 if ( $part->is_assortment ) {
659 $part->assortment_items( @{$self->assortment_items} );
660 if ( $params{price_type} eq 'lastcost' ) {
661 return $part->items_lastcost_sum;
663 if ( $params{pricegroup_id} ) {
664 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
666 return $part->items_sellprice_sum;
669 } elsif ( $part->is_assembly ) {
670 $part->assemblies( @{$self->assembly_items} );
671 if ( $params{price_type} eq 'lastcost' ) {
672 return $part->items_lastcost_sum;
674 return $part->items_sellprice_sum;
679 sub check_part_not_modified {
682 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
689 my $is_new = !$self->part->id;
691 my $params = delete($::form->{part}) || { };
693 delete $params->{id};
694 $self->part->assign_attributes(%{ $params});
695 $self->part->bin_id(undef) unless $self->part->warehouse_id;
697 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
698 # will be the case for used assortments when saving, or when a used assortment
700 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
701 $self->part->assortment_items([]);
702 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
705 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
706 $self->part->assemblies([]); # completely rewrite assortments each time
707 $self->part->add_assemblies( @{ $self->assembly_items } );
710 $self->part->translations([]);
711 $self->parse_form_translations;
713 $self->part->prices([]);
714 $self->parse_form_prices;
716 $self->parse_form_makemodels;
719 sub parse_form_prices {
721 # only save prices > 0
722 my $prices = delete($::form->{prices}) || [];
723 foreach my $price ( @{$prices} ) {
724 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
725 next unless $sellprice > 0; # skip negative prices as well
726 my $p = SL::DB::Price->new(parts_id => $self->part->id,
727 pricegroup_id => $price->{pricegroup_id},
730 $self->part->add_prices($p);
734 sub parse_form_translations {
736 # don't add empty translations
737 my $translations = delete($::form->{translations}) || [];
738 foreach my $translation ( @{$translations} ) {
739 next unless $translation->{translation};
740 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
741 $self->part->add_translations( $translation );
745 sub parse_form_makemodels {
749 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
750 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
753 $self->part->makemodels([]);
756 my $makemodels = delete($::form->{makemodels}) || [];
757 foreach my $makemodel ( @{$makemodels} ) {
758 next unless $makemodel->{make};
760 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
762 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
763 id => $makemodel->{id},
764 make => $makemodel->{make},
765 model => $makemodel->{model} || '',
766 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
767 sortorder => $position,
769 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
770 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
771 # don't change lastupdate
772 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
773 # new makemodel, no lastcost entered, leave lastupdate empty
774 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
775 # lastcost hasn't changed, use original lastupdate
776 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
778 $mm->lastupdate(DateTime->now);
780 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
781 $self->part->add_makemodels($mm);
785 sub build_bin_select {
786 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
787 title_key => 'description',
788 default => $_[0]->bin->id,
792 # get_set_inits for partpicker
795 if ($::form->{no_paginate}) {
796 $_[0]->models->disable_plugin('paginated');
802 # get_set_inits for part controller
806 # used by edit, save, delete and add
808 if ( $::form->{part}{id} ) {
809 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup shop_parts shop_parts.shop) ]);
811 die "part_type missing" unless $::form->{part}{part_type};
812 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
818 return $self->part->orphaned;
824 SL::Controller::Helper::GetModels->new(
831 partnumber => t8('Partnumber'),
832 description => t8('Description'),
834 with_objects => [ qw(unit_obj classification) ],
843 sub init_assortment_items {
844 # this init is used while saving and whenever assortments change dynamically
848 my $assortment_items = delete($::form->{assortment_items}) || [];
849 foreach my $assortment_item ( @{$assortment_items} ) {
850 next unless $assortment_item->{parts_id};
852 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
853 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
854 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
855 charge => $assortment_item->{charge},
856 unit => $assortment_item->{unit} || $part->unit,
857 position => $position,
865 sub init_makemodels {
869 my @makemodel_array = ();
870 my $makemodels = delete($::form->{makemodels}) || [];
872 foreach my $makemodel ( @{$makemodels} ) {
873 next unless $makemodel->{make};
875 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
876 id => $makemodel->{id},
877 make => $makemodel->{make},
878 model => $makemodel->{model} || '',
879 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
880 sortorder => $position,
881 ) or die "Can't create mm";
882 # $mm->id($makemodel->{id}) if $makemodel->{id};
883 push(@makemodel_array, $mm);
885 return \@makemodel_array;
888 sub init_assembly_items {
892 my $assembly_items = delete($::form->{assembly_items}) || [];
893 foreach my $assembly_item ( @{$assembly_items} ) {
894 next unless $assembly_item->{parts_id};
896 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
897 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
898 bom => $assembly_item->{bom},
899 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
900 position => $position,
907 sub init_all_warehouses {
909 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
912 sub init_all_languages {
913 SL::DB::Manager::Language->get_all_sorted;
916 sub init_all_partsgroups {
918 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
921 sub init_all_buchungsgruppen {
923 if ( $self->part->orphaned ) {
924 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
926 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
930 sub init_shops_not_assigned {
933 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
934 if ( @used_shop_ids ) {
935 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
938 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
944 if ( $self->part->orphaned ) {
945 return SL::DB::Manager::Unit->get_all_sorted;
947 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
951 sub init_all_payment_terms {
953 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
956 sub init_all_price_factors {
957 SL::DB::Manager::PriceFactor->get_all_sorted;
960 sub init_all_pricegroups {
961 SL::DB::Manager::Pricegroup->get_all_sorted;
964 # model used to filter/display the parts in the multi-items dialog
965 sub init_multi_items_models {
966 SL::Controller::Helper::GetModels->new(
969 with_objects => [ qw(unit_obj partsgroup classification) ],
970 disable_plugin => 'paginated',
971 source => $::form->{multi_items},
977 partnumber => t8('Partnumber'),
978 description => t8('Description')}
982 sub init_parts_classification_filter {
983 return [] unless $::form->{parts_classification_type};
985 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
986 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
988 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
991 # simple checks to run on $::form before saving
993 sub form_check_part_description_exists {
996 return 1 if $::form->{part}{description};
998 $self->js->flash('error', t8('Part Description missing!'))
999 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1000 ->focus('#part_description');
1004 sub form_check_assortment_items_exist {
1007 return 1 unless $::form->{part}{part_type} eq 'assortment';
1008 # skip item check for existing assortments that have been used
1009 return 1 if ($self->part->id and !$self->part->orphaned);
1011 # new or orphaned parts must have items in $::form->{assortment_items}
1012 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1013 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1014 ->focus('#add_assortment_item_name')
1015 ->flash('error', t8('The assortment doesn\'t have any items.'));
1021 sub form_check_assortment_items_unique {
1024 return 1 unless $::form->{part}{part_type} eq 'assortment';
1026 my %duplicate_elements;
1028 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1029 $duplicate_elements{$_}++ if $count{$_}++;
1032 if ( keys %duplicate_elements ) {
1033 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1034 ->flash('error', t8('There are duplicate assortment items'));
1040 sub form_check_assembly_items_exist {
1043 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1045 # skip item check for existing assembly that have been used
1046 return 1 if ($self->part->id and !$self->part->orphaned);
1048 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1049 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1050 ->focus('#add_assembly_item_name')
1051 ->flash('error', t8('The assembly doesn\'t have any items.'));
1057 sub form_check_partnumber_is_unique {
1060 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1061 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1063 $self->js->flash('error', t8('The partnumber already exists!'))
1064 ->focus('#part_description');
1071 # general checking functions
1074 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1080 $self->form_check_part_description_exists || return 0;
1081 $self->form_check_assortment_items_exist || return 0;
1082 $self->form_check_assortment_items_unique || return 0;
1083 $self->form_check_assembly_items_exist || return 0;
1084 $self->form_check_partnumber_is_unique || return 0;
1089 sub check_has_valid_part_type {
1090 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1093 sub render_assortment_items_to_html {
1094 my ($self, $assortment_items, $number_of_items) = @_;
1096 my $position = $number_of_items + 1;
1098 foreach my $ai (@$assortment_items) {
1099 $html .= $self->p->render('part/_assortment_row',
1100 PART => $self->part,
1101 orphaned => $self->orphaned,
1103 listrow => $position % 2 ? 1 : 0,
1104 position => $position, # for legacy assemblies
1111 sub render_assembly_items_to_html {
1112 my ($self, $assembly_items, $number_of_items) = @_;
1114 my $position = $number_of_items + 1;
1116 foreach my $ai (@{$assembly_items}) {
1117 $html .= $self->p->render('part/_assembly_row',
1118 PART => $self->part,
1119 orphaned => $self->orphaned,
1121 listrow => $position % 2 ? 1 : 0,
1122 position => $position, # for legacy assemblies
1129 sub parse_add_items_to_objects {
1130 my ($self, %params) = @_;
1131 my $part_type = $params{part_type};
1132 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1133 my $position = $params{position} || 1;
1135 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1138 foreach my $item ( @add_items ) {
1139 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1141 if ( $part_type eq 'assortment' ) {
1142 $ai = SL::DB::AssortmentItem->new(part => $part,
1143 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1144 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1145 position => $position,
1146 ) or die "Can't create AssortmentItem from item";
1147 } elsif ( $part_type eq 'assembly' ) {
1148 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1149 # id => $self->assembly->id, # will be set on save
1150 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1151 bom => 0, # default when adding: no bom
1152 position => $position,
1155 die "part_type must be assortment or assembly";
1157 push(@item_objects, $ai);
1161 return \@item_objects;
1164 sub _setup_form_action_bar {
1167 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1169 for my $bar ($::request->layout->get('actionbar')) {
1174 call => [ 'kivi.Part.save' ],
1175 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1176 accesskey => 'enter',
1180 call => [ 'kivi.Part.use_as_new' ],
1181 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1182 : !$may_edit ? t8('You do not have the permissions to access this function.')
1185 ], # end of combobox "Save"
1189 call => [ 'kivi.Part.delete' ],
1190 confirm => t8('Do you really want to delete this object?'),
1191 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1192 : !$may_edit ? t8('You do not have the permissions to access this function.')
1193 : !$self->part->orphaned ? t8('This object has already been used.')
1201 call => [ 'kivi.Part.open_history_popup' ],
1202 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1203 : !$may_edit ? t8('You do not have the permissions to access this function.')
1218 SL::Controller::Part - Part CRUD controller
1222 Controller for adding/editing/saving/deleting parts.
1224 All the relations are loaded at once and saving the part, adding a history
1225 entry and saving CVars happens inside one transaction. When saving the old
1226 relations are deleted and written as new to the database.
1228 Relations for parts:
1236 =item assembly items
1238 =item assortment items
1246 There are 4 different part types:
1252 The "default" part type.
1254 inventory_accno_id is set.
1258 Services can't be stocked.
1260 inventory_accno_id isn't set.
1264 Assemblies consist of other parts, services, assemblies or assortments. They
1265 aren't meant to be bought, only sold. To add assemblies to stock you typically
1266 have to make them, which reduces the stock by its respective components. Once
1267 an assembly item has been created there is currently no way to "disassemble" it
1268 again. An assembly item can appear several times in one assembly. An assmbly is
1269 sold as one item with a defined sellprice and lastcost. If the component prices
1270 change the assortment price remains the same. The assembly items may be printed
1271 in a record if the item's "bom" is set.
1275 Similar to assembly, but each assortment item may only appear once per
1276 assortment. When selling an assortment the assortment items are added to the
1277 record together with the assortment, which is added with sellprice 0.
1279 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1280 determined by the sum of the current assortment item prices when the assortment
1281 is added to a record. This also means that price rules and customer discounts
1282 will be applied to the assortment items.
1284 Once the assortment items have been added they may be modified or deleted, just
1285 as if they had been added manually, the individual assortment items aren't
1286 linked to the assortment or the other assortment items in any way.
1294 =item C<action_add_part>
1296 =item C<action_add_service>
1298 =item C<action_add_assembly>
1300 =item C<action_add_assortment>
1302 =item C<action_add PART_TYPE>
1304 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1305 parameter part_type as an action. Example:
1307 controller.pl?action=Part/add&part_type=service
1309 =item C<action_add_from_record>
1311 When adding new items to records they can be created on the fly if the entered
1312 partnumber or description doesn't exist yet. After being asked what part type
1313 the new item should have the user is redirected to the correct edit page.
1315 Depending on whether the item was added from a sales or a purchase record, only
1316 the relevant part classifications should be selectable for new item, so this
1317 parameter is passed on via a hidden parts_classification_type in the new_item
1320 =item C<action_save>
1322 Saves the current part and then reloads the edit page for the part.
1324 =item C<action_use_as_new>
1326 Takes the information from the current part, plus any modifications made on the
1327 page, and creates a new edit page that is ready to be saved. The partnumber is
1328 set empty, so a new partnumber from the number range will be used if the user
1329 doesn't enter one manually.
1331 Unsaved changes to the original part aren't updated.
1333 The part type cannot be changed in this way.
1335 =item C<action_delete>
1337 Deletes the current part and then redirects to the main page, there is no
1340 The delete button only appears if the part is 'orphaned', according to
1341 SL::DB::Part orphaned.
1343 The part can't be deleted if it appears in invoices, orders, delivery orders,
1344 the inventory, or is part of an assembly or assortment.
1346 If the part is deleted its relations prices, makdemodel, assembly,
1347 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1349 Before this controller items that appeared in inventory didn't count as
1350 orphaned and could be deleted and the inventory entries were also deleted, this
1351 "feature" hasn't been implemented.
1353 =item C<action_edit part.id>
1355 Load and display a part for editing.
1357 controller.pl?action=Part/edit&part.id=12345
1359 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1363 =head1 BUTTON ACTIONS
1369 Opens a popup displaying all the history entries. Once a new history controller
1370 is written the button could link there instead, with the part already selected.
1378 =item C<action_update_item_totals>
1380 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1381 amount of an item changes. The sum of all sellprices and lastcosts is
1382 calculated and the totals updated. Uses C<recalc_item_totals>.
1384 =item C<action_add_assortment_item>
1386 Adds a new assortment item from a part picker seleciton to the assortment item list
1388 If the item already exists in the assortment the item isn't added and a Flash
1391 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1392 after adding each new item, add the new object to the item objects that were
1393 already parsed, calculate totals via a dummy part then update the row and the
1396 =item C<action_add_assembly_item>
1398 Adds a new assembly item from a part picker seleciton to the assembly item list
1400 If the item already exists in the assembly a flash info is generated, but the
1403 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1404 after adding each new item, add the new object to the item objects that were
1405 already parsed, calculate totals via a dummy part then update the row and the
1408 =item C<action_add_multi_assortment_items>
1410 Parses the items to be added from the form generated by the multi input and
1411 appends the html of the tr-rows to the assortment item table. Afterwards all
1412 assortment items are renumbered and the sums recalculated via
1413 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1415 =item C<action_add_multi_assembly_items>
1417 Parses the items to be added from the form generated by the multi input and
1418 appends the html of the tr-rows to the assembly item table. Afterwards all
1419 assembly items are renumbered and the sums recalculated via
1420 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1422 =item C<action_show_multi_items_dialog>
1424 =item C<action_multi_items_update_result>
1426 =item C<action_add_makemodel_row>
1428 Add a new makemodel row with the vendor that was selected via the vendor
1431 Checks the already existing makemodels and warns if a row with that vendor
1432 already exists. Currently it is possible to have duplicate vendor rows.
1434 =item C<action_reorder_items>
1436 Sorts the item table for assembly or assortment items.
1438 =item C<action_warehouse_changed>
1442 =head1 ACTIONS part picker
1446 =item C<action_ajax_autocomplete>
1448 =item C<action_test_page>
1450 =item C<action_part_picker_search>
1452 =item C<action_part_picker_result>
1454 =item C<action_show>
1464 Calls some simple checks that test the submitted $::form for obvious errors.
1465 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1467 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1468 some cases extra actions are taken, e.g. if the part description is missing the
1469 basic data tab is selected and the description input field is focussed.
1475 =item C<form_check_part_description_exists>
1477 =item C<form_check_assortment_items_exist>
1479 =item C<form_check_assortment_items_unique>
1481 =item C<form_check_assembly_items_exist>
1483 =item C<form_check_partnumber_is_unique>
1487 =head1 HELPER FUNCTIONS
1493 When submitting the form for saving, parses the transmitted form. Expects the
1497 $::form->{makemodels}
1498 $::form->{translations}
1500 $::form->{assemblies}
1501 $::form->{assortments}
1503 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1505 =item C<recalc_item_totals %params>
1507 Helper function for calculating the total lastcost and sellprice for assemblies
1508 or assortments according to their items, which are parsed from the current
1511 Is called whenever the qty of an item is changed or items are deleted.
1515 * part_type : 'assortment' or 'assembly' (mandatory)
1517 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1519 Depending on the price_type the lastcost sum or sellprice sum is returned.
1521 Doesn't work for recursive items.
1525 =head1 GET SET INITS
1527 There are get_set_inits for
1535 which parse $::form and automatically create an array of objects.
1537 These inits are used during saving and each time a new element is added.
1541 =item C<init_makemodels>
1543 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1544 $self->part->makemodels, ready to be saved.
1546 Used for saving parts and adding new makemodel rows.
1548 =item C<parse_add_items_to_objects PART_TYPE>
1550 Parses the resulting form from either the part-picker submit or the multi-item
1551 submit, and creates an arrayref of assortment_item or assembly objects, that
1552 can be rendered via C<render_assortment_items_to_html> or
1553 C<render_assembly_items_to_html>.
1555 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1556 Optional param: position (used for numbering and listrow class)
1558 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1560 Takes an array_ref of assortment_items, and generates tables rows ready for
1561 adding to the assortment table. Is used when a part is loaded, or whenever new
1562 assortment items are added.
1564 =item C<parse_form_makemodels>
1566 Makemodels can't just be overwritten, because of the field "lastupdate", that
1567 remembers when the lastcost for that vendor changed the last time.
1569 So the original values are cloned and remembered, so we can compare if lastcost
1570 was changed in $::form, and keep or update lastupdate.
1572 lastcost isn't updated until the first time it was saved with a value, until
1575 Also a boolean "makemodel" needs to be written in parts, depending on whether
1576 makemodel entries exist or not.
1578 We still need init_makemodels for when we open the part for editing.
1588 It should be possible to jump to the edit page in a specific tab
1592 Support callbacks, e.g. creating a new part from within an order, and jumping
1593 back to the order again afterwards.
1597 Support units when adding assembly items or assortment items. Currently the
1598 default unit of the item is always used.
1602 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1603 consists of other assemblies.
1609 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>