1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
9 use SL::DB::PriceRuleItem;
11 use SL::Controller::Helper::GetModels;
12 use SL::Locale::String qw(t8);
14 use List::Util qw(sum);
15 use SL::Helper::Flash;
19 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
21 use SL::MoreCommon qw(save_form);
23 use SL::Presenter::EscapedText qw(escape is_escaped);
24 use SL::Presenter::Tag qw(select_tag);
26 use Rose::Object::MakeMethods::Generic (
27 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
28 makemodels shops_not_assigned
31 assortment assortment_items assembly assembly_items
32 all_pricegroups all_translations all_partsgroups all_units
33 all_buchungsgruppen all_payment_terms all_warehouses
34 parts_classification_filter
35 all_languages all_units all_price_factors) ],
36 'scalar' => [ qw(warehouse bin stock_amounts journal) ],
40 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
41 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
43 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
45 # actions for editing parts
48 my ($self, %params) = @_;
50 $self->part( SL::DB::Part->new_part );
54 sub action_add_service {
55 my ($self, %params) = @_;
57 $self->part( SL::DB::Part->new_service );
61 sub action_add_assembly {
62 my ($self, %params) = @_;
64 $self->part( SL::DB::Part->new_assembly );
68 sub action_add_assortment {
69 my ($self, %params) = @_;
71 $self->part( SL::DB::Part->new_assortment );
75 sub action_add_from_record {
78 check_has_valid_part_type($::form->{part}{part_type});
80 die 'parts_classification_type must be "sales" or "purchases"'
81 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
90 check_has_valid_part_type($::form->{part_type});
92 $self->action_add_part if $::form->{part_type} eq 'part';
93 $self->action_add_service if $::form->{part_type} eq 'service';
94 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
95 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
99 my ($self, %params) = @_;
101 # checks that depend only on submitted $::form
102 $self->check_form or return $self->js->render;
104 my $is_new = !$self->part->id; # $ part gets loaded here
106 # check that the part hasn't been modified
108 $self->check_part_not_modified or
109 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;
113 && $::form->{part}{partnumber}
114 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
116 return $self->js->error(t8('The partnumber is already being used'))->render;
121 my @errors = $self->part->validate;
122 return $self->js->error(@errors)->render if @errors;
124 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
125 $self->part->db->with_transaction(sub {
127 if ( $params{save_as_new} ) {
128 $self->part( $self->part->clone_and_reset_deep );
129 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
132 $self->part->save(cascade => 1);
134 SL::DB::History->new(
135 trans_id => $self->part->id,
136 snumbers => 'partnumber_' . $self->part->partnumber,
137 employee_id => SL::DB::Manager::Employee->current->id,
142 CVar->save_custom_variables(
143 dbh => $self->part->db->dbh,
145 trans_id => $self->part->id,
146 variables => $::form, # $::form->{cvar} would be nicer
151 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
154 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
156 if ( $::form->{callback} ) {
157 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
160 # default behaviour after save: reload item, this also resets last_modification!
161 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
165 sub action_save_as_new {
167 $self->action_save(save_as_new=>1);
173 my $db = $self->part->db; # $self->part has a get_set_init on $::form
175 my $partnumber = $self->part->partnumber; # remember for history log
180 # delete part, together with relationships that don't already
181 # have an ON DELETE CASCADE, e.g. makemodel and translation.
182 $self->part->delete(cascade => 1);
184 SL::DB::History->new(
185 trans_id => $self->part->id,
186 snumbers => 'partnumber_' . $partnumber,
187 employee_id => SL::DB::Manager::Employee->current->id,
189 addition => 'DELETED',
192 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
194 flash_later('info', t8('The item has been deleted.'));
195 if ( $::form->{callback} ) {
196 $self->redirect_to($::form->unescape($::form->{callback}));
198 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
202 sub action_use_as_new {
203 my ($self, %params) = @_;
205 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
206 $::form->{oldpartnumber} = $oldpart->partnumber;
208 $self->part($oldpart->clone_and_reset_deep);
210 $self->part->partnumber(undef);
216 my ($self, %params) = @_;
222 my ($self, %params) = @_;
224 $self->_set_javascript;
225 $self->_setup_form_action_bar;
227 my (%assortment_vars, %assembly_vars);
228 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
229 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
231 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
233 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
234 if (scalar @{ $params{CUSTOM_VARIABLES} });
236 my %title_hash = ( part => t8('Edit Part'),
237 assembly => t8('Edit Assembly'),
238 service => t8('Edit Service'),
239 assortment => t8('Edit Assortment'),
242 $self->part->prices([]) unless $self->part->prices;
243 $self->part->translations([]) unless $self->part->translations;
247 title => $title_hash{$self->part->part_type},
250 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
251 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
252 oldpartnumber => $::form->{oldpartnumber},
253 old_id => $::form->{old_id},
261 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
262 $_[0]->render('part/history', { layout => 0 },
263 history_entries => $history_entries);
266 sub action_inventory {
269 $::auth->assert('warehouse_contents');
271 $self->stock_amounts($self->part->get_simple_stock_sql);
272 $self->journal($self->part->get_mini_journal);
274 $_[0]->render('part/_inventory_data', { layout => 0 });
277 sub action_update_item_totals {
280 my $part_type = $::form->{part_type};
281 die unless $part_type =~ /^(assortment|assembly)$/;
283 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
284 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
286 my $sum_diff = $sellprice_sum-$lastcost_sum;
289 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
290 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
291 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
292 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
293 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
294 ->no_flash_clear->render();
297 sub action_add_multi_assortment_items {
300 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
301 my $html = $self->render_assortment_items_to_html($item_objects);
303 $self->js->run('kivi.Part.close_picker_dialogs')
304 ->append('#assortment_rows', $html)
305 ->run('kivi.Part.renumber_positions')
306 ->run('kivi.Part.assortment_recalc')
310 sub action_add_multi_assembly_items {
313 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
315 foreach my $item (@{$item_objects}) {
316 my $errstr = validate_assembly($item->part,$self->part);
317 $self->js->flash('error',$errstr) if $errstr;
318 push (@checked_objects,$item) unless $errstr;
321 my $html = $self->render_assembly_items_to_html(\@checked_objects);
323 $self->js->run('kivi.Part.close_picker_dialogs')
324 ->append('#assembly_rows', $html)
325 ->run('kivi.Part.renumber_positions')
326 ->run('kivi.Part.assembly_recalc')
330 sub action_add_assortment_item {
331 my ($self, %params) = @_;
333 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
335 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
337 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
338 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
339 return $self->js->flash('error', t8("This part has already been added."))->render;
342 my $number_of_items = scalar @{$self->assortment_items};
343 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
344 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
346 push(@{$self->assortment_items}, @{$item_objects});
347 my $part = SL::DB::Part->new(part_type => 'assortment');
348 $part->assortment_items(@{$self->assortment_items});
349 my $items_sellprice_sum = $part->items_sellprice_sum;
350 my $items_lastcost_sum = $part->items_lastcost_sum;
351 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
354 ->append('#assortment_rows' , $html) # append in tbody
355 ->val('.add_assortment_item_input' , '')
356 ->run('kivi.Part.focus_last_assortment_input')
357 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
358 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
359 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
360 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
361 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
365 sub action_add_assembly_item {
368 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
370 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
372 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
374 my $duplicate_warning = 0; # duplicates are allowed, just warn
375 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
376 $duplicate_warning++;
379 my $number_of_items = scalar @{$self->assembly_items};
380 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
382 foreach my $item (@{$item_objects}) {
383 my $errstr = validate_assembly($item->part,$self->part);
384 return $self->js->flash('error',$errstr)->render if $errstr;
389 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
391 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
393 push(@{$self->assembly_items}, @{$item_objects});
394 my $part = SL::DB::Part->new(part_type => 'assembly');
395 $part->assemblies(@{$self->assembly_items});
396 my $items_sellprice_sum = $part->items_sellprice_sum;
397 my $items_lastcost_sum = $part->items_lastcost_sum;
398 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
401 ->append('#assembly_rows', $html) # append in tbody
402 ->val('.add_assembly_item_input' , '')
403 ->run('kivi.Part.focus_last_assembly_input')
404 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
405 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
406 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
407 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
408 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
412 sub action_show_multi_items_dialog {
415 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
416 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
417 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
419 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
420 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
421 search_term => $search_term
425 sub action_multi_items_update_result {
428 my $count = $_[0]->multi_items_models->count;
431 my $text = escape($::locale->text('No results.'));
432 $_[0]->render($text, { layout => 0 });
433 } elsif ($count > $max_count) {
434 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
435 $_[0]->render($text, { layout => 0 });
437 my $multi_items = $_[0]->multi_items_models->get;
438 $_[0]->render('part/_multi_items_result', { layout => 0 },
439 multi_items => $multi_items);
443 sub action_add_makemodel_row {
446 my $vendor_id = $::form->{add_makemodel};
448 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
449 return $self->js->error(t8("No vendor selected or found!"))->render;
451 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
452 $self->js->flash('info', t8("This vendor has already been added."));
455 my $position = scalar @{$self->makemodels} + 1;
457 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
461 sortorder => $position,
462 ) or die "Can't create MakeModel object";
464 my $row_as_html = $self->p->render('part/_makemodel_row',
466 listrow => $position % 2 ? 0 : 1,
469 # after selection focus on the model field in the row that was just added
471 ->append('#makemodel_rows', $row_as_html) # append in tbody
472 ->val('.add_makemodel_input', '')
473 ->run('kivi.Part.focus_last_makemodel_input')
477 sub action_add_customerprice_row {
480 my $customer_id = $::form->{add_customerprice};
482 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
483 or return $self->js->error(t8("No customer selected or found!"))->render;
485 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
486 $self->js->flash('info', t8("This customer has already been added."));
489 my $position = scalar @{ $self->customerprices } + 1;
491 my $cu = SL::DB::PartCustomerPrice->new(
492 customer_id => $customer->id,
493 customer_partnumber => '',
495 sortorder => $position,
496 ) or die "Can't create Customerprice object";
498 my $row_as_html = $self->p->render(
499 'part/_customerprice_row',
500 customerprice => $cu,
501 listrow => $position % 2 ? 0
505 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
506 ->val('.add_customerprice_input', '')
507 ->run('kivi.Part.focus_last_customerprice_input')->render;
510 sub action_reorder_items {
513 my $part_type = $::form->{part_type};
516 partnumber => sub { $_[0]->part->partnumber },
517 description => sub { $_[0]->part->description },
518 qty => sub { $_[0]->qty },
519 sellprice => sub { $_[0]->part->sellprice },
520 lastcost => sub { $_[0]->part->lastcost },
521 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
524 my $method = $sort_keys{$::form->{order_by}};
527 if ($part_type eq 'assortment') {
528 @items = @{ $self->assortment_items };
530 @items = @{ $self->assembly_items };
533 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
534 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
535 if ($::form->{sort_dir}) {
536 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
538 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
541 if ($::form->{sort_dir}) {
542 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
544 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
548 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
551 sub action_warehouse_changed {
554 if ($::form->{warehouse_id} ) {
555 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
556 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
558 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
559 $self->bin($self->warehouse->bins->[0]);
561 ->html('#bin', $self->build_bin_select)
562 ->focus('#part_bin_id');
563 return $self->js->render;
567 # no warehouse was selected, empty the bin field and reset the id
569 ->val('#part_bin_id', undef)
572 return $self->js->render;
575 sub action_ajax_autocomplete {
576 my ($self, %params) = @_;
578 # if someone types something, and hits enter, assume he entered the full name.
579 # if something matches, treat that as sole match
580 # since we need a second get models instance with different filters for that,
581 # we only modify the original filter temporarily in place
582 if ($::form->{prefer_exact}) {
583 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
584 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
585 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
587 my $exact_models = SL::Controller::Helper::GetModels->new(
590 paginated => { per_page => 2 },
591 with_objects => [ qw(unit_obj classification) ],
594 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
595 $self->parts($exact_matches);
601 value => $_->displayable_name,
602 label => $_->displayable_name,
604 partnumber => $_->partnumber,
605 description => $_->description,
607 part_type => $_->part_type,
609 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
611 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
613 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
616 sub action_test_page {
617 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
620 sub action_part_picker_search {
623 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
624 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
625 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
627 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
630 sub action_part_picker_result {
631 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
637 if ($::request->type eq 'json') {
642 $part_hash = $self->part->as_tree;
643 $part_hash->{cvars} = $self->part->cvar_as_hashref;
646 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
651 sub validate_add_items {
652 scalar @{$::form->{add_items}};
655 sub prepare_assortment_render_vars {
658 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
659 items_lastcost_sum => $self->part->items_lastcost_sum,
660 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
662 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
667 sub prepare_assembly_render_vars {
670 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
672 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
673 items_lastcost_sum => $self->part->items_lastcost_sum,
674 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
676 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
684 check_has_valid_part_type($self->part->part_type);
686 $self->_set_javascript;
687 $self->_setup_form_action_bar;
689 my %title_hash = ( part => t8('Add Part'),
690 assembly => t8('Add Assembly'),
691 service => t8('Add Service'),
692 assortment => t8('Add Assortment'),
697 title => $title_hash{$self->part->part_type},
702 sub _set_javascript {
704 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
705 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
708 sub recalc_item_totals {
709 my ($self, %params) = @_;
711 if ( $params{part_type} eq 'assortment' ) {
712 return 0 unless scalar @{$self->assortment_items};
713 } elsif ( $params{part_type} eq 'assembly' ) {
714 return 0 unless scalar @{$self->assembly_items};
716 carp "can only calculate sum for assortments and assemblies";
719 my $part = SL::DB::Part->new(part_type => $params{part_type});
720 if ( $part->is_assortment ) {
721 $part->assortment_items( @{$self->assortment_items} );
722 if ( $params{price_type} eq 'lastcost' ) {
723 return $part->items_lastcost_sum;
725 if ( $params{pricegroup_id} ) {
726 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
728 return $part->items_sellprice_sum;
731 } elsif ( $part->is_assembly ) {
732 $part->assemblies( @{$self->assembly_items} );
733 if ( $params{price_type} eq 'lastcost' ) {
734 return $part->items_lastcost_sum;
736 return $part->items_sellprice_sum;
741 sub check_part_not_modified {
744 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
751 my $is_new = !$self->part->id;
753 my $params = delete($::form->{part}) || { };
755 delete $params->{id};
756 $self->part->assign_attributes(%{ $params});
757 $self->part->bin_id(undef) unless $self->part->warehouse_id;
759 $self->normalize_text_blocks;
761 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
762 # will be the case for used assortments when saving, or when a used assortment
764 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
765 $self->part->assortment_items([]);
766 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
769 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
770 $self->part->assemblies([]); # completely rewrite assortments each time
771 $self->part->add_assemblies( @{ $self->assembly_items } );
774 $self->part->translations([]);
775 $self->parse_form_translations;
777 $self->part->prices([]);
778 $self->parse_form_prices;
780 $self->parse_form_customerprices;
781 $self->parse_form_makemodels;
784 sub parse_form_prices {
786 # only save prices > 0
787 my $prices = delete($::form->{prices}) || [];
788 foreach my $price ( @{$prices} ) {
789 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
790 next unless $sellprice > 0; # skip negative prices as well
791 my $p = SL::DB::Price->new(parts_id => $self->part->id,
792 pricegroup_id => $price->{pricegroup_id},
795 $self->part->add_prices($p);
799 sub parse_form_translations {
801 # don't add empty translations
802 my $translations = delete($::form->{translations}) || [];
803 foreach my $translation ( @{$translations} ) {
804 next unless $translation->{translation};
805 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
806 $self->part->add_translations( $translation );
810 sub parse_form_makemodels {
814 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
815 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
818 $self->part->makemodels([]);
821 my $makemodels = delete($::form->{makemodels}) || [];
822 foreach my $makemodel ( @{$makemodels} ) {
823 next unless $makemodel->{make};
825 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
827 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
828 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
830 make => $makemodel->{make},
831 model => $makemodel->{model} || '',
832 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
833 sortorder => $position,
835 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
836 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
837 # don't change lastupdate
838 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
839 # new makemodel, no lastcost entered, leave lastupdate empty
840 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
841 # lastcost hasn't changed, use original lastupdate
842 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
844 $mm->lastupdate(DateTime->now);
846 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
847 $self->part->add_makemodels($mm);
851 sub parse_form_customerprices {
854 my $customerprices_map;
855 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
856 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
859 $self->part->customerprices([]);
862 my $customerprices = delete($::form->{customerprices}) || [];
863 foreach my $customerprice ( @{$customerprices} ) {
864 next unless $customerprice->{customer_id};
866 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
868 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
869 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
871 customer_id => $customerprice->{customer_id},
872 customer_partnumber => $customerprice->{customer_partnumber} || '',
873 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
874 sortorder => $position,
876 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
877 # lastupdate isn't set, original price is 0 and new lastcost is 0
878 # don't change lastupdate
879 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
880 # new customerprice, no lastcost entered, leave lastupdate empty
881 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
882 # price hasn't changed, use original lastupdate
883 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
885 $cu->lastupdate(DateTime->now);
887 $self->part->add_customerprices($cu);
891 sub build_bin_select {
892 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
893 title_key => 'description',
894 default => $_[0]->bin->id,
899 # get_set_inits for partpicker
902 if ($::form->{no_paginate}) {
903 $_[0]->models->disable_plugin('paginated');
909 # get_set_inits for part controller
913 # used by edit, save, delete and add
915 if ( $::form->{part}{id} ) {
916 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
917 } elsif ( $::form->{id} ) {
918 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
920 die "part_type missing" unless $::form->{part}{part_type};
921 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
927 return $self->part->orphaned;
933 SL::Controller::Helper::GetModels->new(
940 partnumber => t8('Partnumber'),
941 description => t8('Description'),
943 with_objects => [ qw(unit_obj classification) ],
952 sub init_assortment_items {
953 # this init is used while saving and whenever assortments change dynamically
957 my $assortment_items = delete($::form->{assortment_items}) || [];
958 foreach my $assortment_item ( @{$assortment_items} ) {
959 next unless $assortment_item->{parts_id};
961 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
962 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
963 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
964 charge => $assortment_item->{charge},
965 unit => $assortment_item->{unit} || $part->unit,
966 position => $position,
974 sub init_makemodels {
978 my @makemodel_array = ();
979 my $makemodels = delete($::form->{makemodels}) || [];
981 foreach my $makemodel ( @{$makemodels} ) {
982 next unless $makemodel->{make};
984 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
985 id => $makemodel->{id},
986 make => $makemodel->{make},
987 model => $makemodel->{model} || '',
988 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
989 sortorder => $position,
990 ) or die "Can't create mm";
991 # $mm->id($makemodel->{id}) if $makemodel->{id};
992 push(@makemodel_array, $mm);
994 return \@makemodel_array;
997 sub init_customerprices {
1001 my @customerprice_array = ();
1002 my $customerprices = delete($::form->{customerprices}) || [];
1004 foreach my $customerprice ( @{$customerprices} ) {
1005 next unless $customerprice->{customer_id};
1007 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1008 id => $customerprice->{id},
1009 customer_partnumber => $customerprice->{customer_partnumber},
1010 customer_id => $customerprice->{customer_id} || '',
1011 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1012 sortorder => $position,
1013 ) or die "Can't create cu";
1014 # $cu->id($customerprice->{id}) if $customerprice->{id};
1015 push(@customerprice_array, $cu);
1017 return \@customerprice_array;
1020 sub init_assembly_items {
1024 my $assembly_items = delete($::form->{assembly_items}) || [];
1025 foreach my $assembly_item ( @{$assembly_items} ) {
1026 next unless $assembly_item->{parts_id};
1028 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1029 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1030 bom => $assembly_item->{bom},
1031 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1032 position => $position,
1039 sub init_all_warehouses {
1041 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1044 sub init_all_languages {
1045 SL::DB::Manager::Language->get_all_sorted;
1048 sub init_all_partsgroups {
1050 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1053 sub init_all_buchungsgruppen {
1055 if ( $self->part->orphaned ) {
1056 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1058 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1062 sub init_shops_not_assigned {
1065 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1066 if ( @used_shop_ids ) {
1067 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1070 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1074 sub init_all_units {
1076 if ( $self->part->orphaned ) {
1077 return SL::DB::Manager::Unit->get_all_sorted;
1079 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1083 sub init_all_payment_terms {
1085 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1088 sub init_all_price_factors {
1089 SL::DB::Manager::PriceFactor->get_all_sorted;
1092 sub init_all_pricegroups {
1093 SL::DB::Manager::Pricegroup->get_all_sorted;
1096 # model used to filter/display the parts in the multi-items dialog
1097 sub init_multi_items_models {
1098 SL::Controller::Helper::GetModels->new(
1099 controller => $_[0],
1101 with_objects => [ qw(unit_obj partsgroup classification) ],
1102 disable_plugin => 'paginated',
1103 source => $::form->{multi_items},
1109 partnumber => t8('Partnumber'),
1110 description => t8('Description')}
1114 sub init_parts_classification_filter {
1115 return [] unless $::form->{parts_classification_type};
1117 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1118 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1120 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1123 # simple checks to run on $::form before saving
1125 sub form_check_part_description_exists {
1128 return 1 if $::form->{part}{description};
1130 $self->js->flash('error', t8('Part Description missing!'))
1131 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1132 ->focus('#part_description');
1136 sub form_check_assortment_items_exist {
1139 return 1 unless $::form->{part}{part_type} eq 'assortment';
1140 # skip item check for existing assortments that have been used
1141 return 1 if ($self->part->id and !$self->part->orphaned);
1143 # new or orphaned parts must have items in $::form->{assortment_items}
1144 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1145 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1146 ->focus('#add_assortment_item_name')
1147 ->flash('error', t8('The assortment doesn\'t have any items.'));
1153 sub form_check_assortment_items_unique {
1156 return 1 unless $::form->{part}{part_type} eq 'assortment';
1158 my %duplicate_elements;
1160 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1161 $duplicate_elements{$_}++ if $count{$_}++;
1164 if ( keys %duplicate_elements ) {
1165 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1166 ->flash('error', t8('There are duplicate assortment items'));
1172 sub form_check_assembly_items_exist {
1175 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1177 # skip item check for existing assembly that have been used
1178 return 1 if ($self->part->id and !$self->part->orphaned);
1180 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1181 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1182 ->focus('#add_assembly_item_name')
1183 ->flash('error', t8('The assembly doesn\'t have any items.'));
1189 sub form_check_partnumber_is_unique {
1192 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1193 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1195 $self->js->flash('error', t8('The partnumber already exists!'))
1196 ->focus('#part_description');
1203 # general checking functions
1206 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1212 $self->form_check_part_description_exists || return 0;
1213 $self->form_check_assortment_items_exist || return 0;
1214 $self->form_check_assortment_items_unique || return 0;
1215 $self->form_check_assembly_items_exist || return 0;
1216 $self->form_check_partnumber_is_unique || return 0;
1221 sub check_has_valid_part_type {
1222 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1226 sub normalize_text_blocks {
1229 # check if feature is enabled (select normalize_part_descriptions from defaults)
1230 return unless ($::instance_conf->get_normalize_part_descriptions);
1233 foreach (qw(description)) {
1234 $self->part->{$_} =~ s/\s+$//s;
1235 $self->part->{$_} =~ s/^\s+//s;
1236 $self->part->{$_} =~ s/ {2,}/ /g;
1238 # html block (caveat: can be circumvented by using bold or italics)
1239 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1240 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1244 sub render_assortment_items_to_html {
1245 my ($self, $assortment_items, $number_of_items) = @_;
1247 my $position = $number_of_items + 1;
1249 foreach my $ai (@$assortment_items) {
1250 $html .= $self->p->render('part/_assortment_row',
1251 PART => $self->part,
1252 orphaned => $self->orphaned,
1254 listrow => $position % 2 ? 1 : 0,
1255 position => $position, # for legacy assemblies
1262 sub render_assembly_items_to_html {
1263 my ($self, $assembly_items, $number_of_items) = @_;
1265 my $position = $number_of_items + 1;
1267 foreach my $ai (@{$assembly_items}) {
1268 $html .= $self->p->render('part/_assembly_row',
1269 PART => $self->part,
1270 orphaned => $self->orphaned,
1272 listrow => $position % 2 ? 1 : 0,
1273 position => $position, # for legacy assemblies
1280 sub parse_add_items_to_objects {
1281 my ($self, %params) = @_;
1282 my $part_type = $params{part_type};
1283 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1284 my $position = $params{position} || 1;
1286 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1289 foreach my $item ( @add_items ) {
1290 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1292 if ( $part_type eq 'assortment' ) {
1293 $ai = SL::DB::AssortmentItem->new(part => $part,
1294 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1295 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1296 position => $position,
1297 ) or die "Can't create AssortmentItem from item";
1298 } elsif ( $part_type eq 'assembly' ) {
1299 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1300 # id => $self->assembly->id, # will be set on save
1301 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1302 bom => 0, # default when adding: no bom
1303 position => $position,
1306 die "part_type must be assortment or assembly";
1308 push(@item_objects, $ai);
1312 return \@item_objects;
1315 sub _setup_form_action_bar {
1318 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1319 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1321 for my $bar ($::request->layout->get('actionbar')) {
1326 call => [ 'kivi.Part.save' ],
1327 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1331 call => [ 'kivi.Part.use_as_new' ],
1332 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1333 : !$may_edit ? t8('You do not have the permissions to access this function.')
1336 ], # end of combobox "Save"
1340 call => [ 'kivi.Part.delete' ],
1341 confirm => t8('Do you really want to delete this object?'),
1342 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1343 : !$may_edit ? t8('You do not have the permissions to access this function.')
1344 : !$self->part->orphaned ? t8('This object has already been used.')
1345 : $used_in_pricerules ? t8('This object is used in price rules.')
1353 call => [ 'kivi.Part.open_history_popup' ],
1354 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1355 : !$may_edit ? t8('You do not have the permissions to access this function.')
1370 SL::Controller::Part - Part CRUD controller
1374 Controller for adding/editing/saving/deleting parts.
1376 All the relations are loaded at once and saving the part, adding a history
1377 entry and saving CVars happens inside one transaction. When saving the old
1378 relations are deleted and written as new to the database.
1380 Relations for parts:
1388 =item assembly items
1390 =item assortment items
1398 There are 4 different part types:
1404 The "default" part type.
1406 inventory_accno_id is set.
1410 Services can't be stocked.
1412 inventory_accno_id isn't set.
1416 Assemblies consist of other parts, services, assemblies or assortments. They
1417 aren't meant to be bought, only sold. To add assemblies to stock you typically
1418 have to make them, which reduces the stock by its respective components. Once
1419 an assembly item has been created there is currently no way to "disassemble" it
1420 again. An assembly item can appear several times in one assembly. An assmbly is
1421 sold as one item with a defined sellprice and lastcost. If the component prices
1422 change the assortment price remains the same. The assembly items may be printed
1423 in a record if the item's "bom" is set.
1427 Similar to assembly, but each assortment item may only appear once per
1428 assortment. When selling an assortment the assortment items are added to the
1429 record together with the assortment, which is added with sellprice 0.
1431 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1432 determined by the sum of the current assortment item prices when the assortment
1433 is added to a record. This also means that price rules and customer discounts
1434 will be applied to the assortment items.
1436 Once the assortment items have been added they may be modified or deleted, just
1437 as if they had been added manually, the individual assortment items aren't
1438 linked to the assortment or the other assortment items in any way.
1446 =item C<action_add_part>
1448 =item C<action_add_service>
1450 =item C<action_add_assembly>
1452 =item C<action_add_assortment>
1454 =item C<action_add PART_TYPE>
1456 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1457 parameter part_type as an action. Example:
1459 controller.pl?action=Part/add&part_type=service
1461 =item C<action_add_from_record>
1463 When adding new items to records they can be created on the fly if the entered
1464 partnumber or description doesn't exist yet. After being asked what part type
1465 the new item should have the user is redirected to the correct edit page.
1467 Depending on whether the item was added from a sales or a purchase record, only
1468 the relevant part classifications should be selectable for new item, so this
1469 parameter is passed on via a hidden parts_classification_type in the new_item
1472 =item C<action_save>
1474 Saves the current part and then reloads the edit page for the part.
1476 =item C<action_use_as_new>
1478 Takes the information from the current part, plus any modifications made on the
1479 page, and creates a new edit page that is ready to be saved. The partnumber is
1480 set empty, so a new partnumber from the number range will be used if the user
1481 doesn't enter one manually.
1483 Unsaved changes to the original part aren't updated.
1485 The part type cannot be changed in this way.
1487 =item C<action_delete>
1489 Deletes the current part and then redirects to the main page, there is no
1492 The delete button only appears if the part is 'orphaned', according to
1493 SL::DB::Part orphaned.
1495 The part can't be deleted if it appears in invoices, orders, delivery orders,
1496 the inventory, or is part of an assembly or assortment.
1498 If the part is deleted its relations prices, makdemodel, assembly,
1499 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1501 Before this controller items that appeared in inventory didn't count as
1502 orphaned and could be deleted and the inventory entries were also deleted, this
1503 "feature" hasn't been implemented.
1505 =item C<action_edit part.id>
1507 Load and display a part for editing.
1509 controller.pl?action=Part/edit&part.id=12345
1511 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1515 =head1 BUTTON ACTIONS
1521 Opens a popup displaying all the history entries. Once a new history controller
1522 is written the button could link there instead, with the part already selected.
1530 =item C<action_update_item_totals>
1532 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1533 amount of an item changes. The sum of all sellprices and lastcosts is
1534 calculated and the totals updated. Uses C<recalc_item_totals>.
1536 =item C<action_add_assortment_item>
1538 Adds a new assortment item from a part picker seleciton to the assortment item list
1540 If the item already exists in the assortment the item isn't added and a Flash
1543 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1544 after adding each new item, add the new object to the item objects that were
1545 already parsed, calculate totals via a dummy part then update the row and the
1548 =item C<action_add_assembly_item>
1550 Adds a new assembly item from a part picker seleciton to the assembly item list
1552 If the item already exists in the assembly a flash info is generated, but the
1555 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1556 after adding each new item, add the new object to the item objects that were
1557 already parsed, calculate totals via a dummy part then update the row and the
1560 =item C<action_add_multi_assortment_items>
1562 Parses the items to be added from the form generated by the multi input and
1563 appends the html of the tr-rows to the assortment item table. Afterwards all
1564 assortment items are renumbered and the sums recalculated via
1565 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1567 =item C<action_add_multi_assembly_items>
1569 Parses the items to be added from the form generated by the multi input and
1570 appends the html of the tr-rows to the assembly item table. Afterwards all
1571 assembly items are renumbered and the sums recalculated via
1572 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1574 =item C<action_show_multi_items_dialog>
1576 =item C<action_multi_items_update_result>
1578 =item C<action_add_makemodel_row>
1580 Add a new makemodel row with the vendor that was selected via the vendor
1583 Checks the already existing makemodels and warns if a row with that vendor
1584 already exists. Currently it is possible to have duplicate vendor rows.
1586 =item C<action_reorder_items>
1588 Sorts the item table for assembly or assortment items.
1590 =item C<action_warehouse_changed>
1594 =head1 ACTIONS part picker
1598 =item C<action_ajax_autocomplete>
1600 =item C<action_test_page>
1602 =item C<action_part_picker_search>
1604 =item C<action_part_picker_result>
1606 =item C<action_show>
1616 Calls some simple checks that test the submitted $::form for obvious errors.
1617 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1619 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1620 some cases extra actions are taken, e.g. if the part description is missing the
1621 basic data tab is selected and the description input field is focussed.
1627 =item C<form_check_part_description_exists>
1629 =item C<form_check_assortment_items_exist>
1631 =item C<form_check_assortment_items_unique>
1633 =item C<form_check_assembly_items_exist>
1635 =item C<form_check_partnumber_is_unique>
1639 =head1 HELPER FUNCTIONS
1645 When submitting the form for saving, parses the transmitted form. Expects the
1649 $::form->{makemodels}
1650 $::form->{translations}
1652 $::form->{assemblies}
1653 $::form->{assortments}
1655 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1657 =item C<recalc_item_totals %params>
1659 Helper function for calculating the total lastcost and sellprice for assemblies
1660 or assortments according to their items, which are parsed from the current
1663 Is called whenever the qty of an item is changed or items are deleted.
1667 * part_type : 'assortment' or 'assembly' (mandatory)
1669 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1671 Depending on the price_type the lastcost sum or sellprice sum is returned.
1673 Doesn't work for recursive items.
1677 =head1 GET SET INITS
1679 There are get_set_inits for
1687 which parse $::form and automatically create an array of objects.
1689 These inits are used during saving and each time a new element is added.
1693 =item C<init_makemodels>
1695 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1696 $self->part->makemodels, ready to be saved.
1698 Used for saving parts and adding new makemodel rows.
1700 =item C<parse_add_items_to_objects PART_TYPE>
1702 Parses the resulting form from either the part-picker submit or the multi-item
1703 submit, and creates an arrayref of assortment_item or assembly objects, that
1704 can be rendered via C<render_assortment_items_to_html> or
1705 C<render_assembly_items_to_html>.
1707 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1708 Optional param: position (used for numbering and listrow class)
1710 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1712 Takes an array_ref of assortment_items, and generates tables rows ready for
1713 adding to the assortment table. Is used when a part is loaded, or whenever new
1714 assortment items are added.
1716 =item C<parse_form_makemodels>
1718 Makemodels can't just be overwritten, because of the field "lastupdate", that
1719 remembers when the lastcost for that vendor changed the last time.
1721 So the original values are cloned and remembered, so we can compare if lastcost
1722 was changed in $::form, and keep or update lastupdate.
1724 lastcost isn't updated until the first time it was saved with a value, until
1727 Also a boolean "makemodel" needs to be written in parts, depending on whether
1728 makemodel entries exist or not.
1730 We still need init_makemodels for when we open the part for editing.
1740 It should be possible to jump to the edit page in a specific tab
1744 Support callbacks, e.g. creating a new part from within an order, and jumping
1745 back to the order again afterwards.
1749 Support units when adding assembly items or assortment items. Currently the
1750 default unit of the item is always used.
1754 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1755 consists of other assemblies.
1761 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>