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 List::UtilsBy qw(extract_by);
16 use SL::Helper::Flash;
20 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
22 use SL::MoreCommon qw(save_form);
24 use SL::Presenter::EscapedText qw(escape is_escaped);
25 use SL::Presenter::Tag qw(select_tag);
27 use Rose::Object::MakeMethods::Generic (
28 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
29 makemodels shops_not_assigned
32 assortment assortment_items assembly assembly_items
33 all_pricegroups all_translations all_partsgroups all_units
34 all_buchungsgruppen all_payment_terms all_warehouses
35 parts_classification_filter
36 all_languages all_units all_price_factors) ],
37 'scalar' => [ qw(warehouse bin stock_amounts journal) ],
41 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
42 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
44 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
46 # actions for editing parts
49 my ($self, %params) = @_;
51 $self->part( SL::DB::Part->new_part );
55 sub action_add_service {
56 my ($self, %params) = @_;
58 $self->part( SL::DB::Part->new_service );
62 sub action_add_assembly {
63 my ($self, %params) = @_;
65 $self->part( SL::DB::Part->new_assembly );
69 sub action_add_assortment {
70 my ($self, %params) = @_;
72 $self->part( SL::DB::Part->new_assortment );
76 sub action_add_from_record {
79 check_has_valid_part_type($::form->{part}{part_type});
81 die 'parts_classification_type must be "sales" or "purchases"'
82 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
91 check_has_valid_part_type($::form->{part_type});
93 $self->action_add_part if $::form->{part_type} eq 'part';
94 $self->action_add_service if $::form->{part_type} eq 'service';
95 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
96 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
100 my ($self, %params) = @_;
102 # checks that depend only on submitted $::form
103 $self->check_form or return $self->js->render;
105 my $is_new = !$self->part->id; # $ part gets loaded here
107 # check that the part hasn't been modified
109 $self->check_part_not_modified or
110 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;
114 && $::form->{part}{partnumber}
115 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
117 return $self->js->error(t8('The partnumber is already being used'))->render;
122 my @errors = $self->part->validate;
123 return $self->js->error(@errors)->render if @errors;
125 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
126 $self->part->db->with_transaction(sub {
128 $self->part->save(cascade => 1);
130 SL::DB::History->new(
131 trans_id => $self->part->id,
132 snumbers => 'partnumber_' . $self->part->partnumber,
133 employee_id => SL::DB::Manager::Employee->current->id,
138 CVar->save_custom_variables(
139 dbh => $self->part->db->dbh,
141 trans_id => $self->part->id,
142 variables => $::form, # $::form->{cvar} would be nicer
147 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
149 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
151 if ( $::form->{callback} ) {
152 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
155 # default behaviour after save: reload item, this also resets last_modification!
156 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
163 my $db = $self->part->db; # $self->part has a get_set_init on $::form
165 my $partnumber = $self->part->partnumber; # remember for history log
170 # delete part, together with relationships that don't already
171 # have an ON DELETE CASCADE, e.g. makemodel and translation.
172 $self->part->delete(cascade => 1);
174 SL::DB::History->new(
175 trans_id => $self->part->id,
176 snumbers => 'partnumber_' . $partnumber,
177 employee_id => SL::DB::Manager::Employee->current->id,
179 addition => 'DELETED',
182 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
184 flash_later('info', t8('The item has been deleted.'));
185 if ( $::form->{callback} ) {
186 $self->redirect_to($::form->unescape($::form->{callback}));
188 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
192 sub action_use_as_new {
193 my ($self, %params) = @_;
195 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
196 $::form->{oldpartnumber} = $oldpart->partnumber;
198 $self->part($oldpart->clone_and_reset_deep);
200 $self->part->partnumber(undef);
206 my ($self, %params) = @_;
212 my ($self, %params) = @_;
214 $self->_set_javascript;
215 $self->_setup_form_action_bar;
217 my (%assortment_vars, %assembly_vars);
218 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
219 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
221 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
223 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
224 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
225 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
226 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
229 my %title_hash = ( part => t8('Edit Part'),
230 assembly => t8('Edit Assembly'),
231 service => t8('Edit Service'),
232 assortment => t8('Edit Assortment'),
235 $self->part->prices([]) unless $self->part->prices;
236 $self->part->translations([]) unless $self->part->translations;
240 title => $title_hash{$self->part->part_type},
243 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
244 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
245 oldpartnumber => $::form->{oldpartnumber},
246 old_id => $::form->{old_id},
254 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
255 $_[0]->render('part/history', { layout => 0 },
256 history_entries => $history_entries);
259 sub action_inventory {
262 $::auth->assert('warehouse_contents');
264 $self->stock_amounts($self->part->get_simple_stock_sql);
265 $self->journal($self->part->get_mini_journal);
267 $_[0]->render('part/_inventory_data', { layout => 0 });
270 sub action_update_item_totals {
273 my $part_type = $::form->{part_type};
274 die unless $part_type =~ /^(assortment|assembly)$/;
276 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
277 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
278 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
280 my $sum_diff = $sellprice_sum-$lastcost_sum;
283 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
284 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
285 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
286 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
287 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
288 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
289 ->no_flash_clear->render();
292 sub action_add_multi_assortment_items {
295 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
296 my $html = $self->render_assortment_items_to_html($item_objects);
298 $self->js->run('kivi.Part.close_picker_dialogs')
299 ->append('#assortment_rows', $html)
300 ->run('kivi.Part.renumber_positions')
301 ->run('kivi.Part.assortment_recalc')
305 sub action_add_multi_assembly_items {
308 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
310 foreach my $item (@{$item_objects}) {
311 my $errstr = validate_assembly($item->part,$self->part);
312 $self->js->flash('error',$errstr) if $errstr;
313 push (@checked_objects,$item) unless $errstr;
316 my $html = $self->render_assembly_items_to_html(\@checked_objects);
318 $self->js->run('kivi.Part.close_picker_dialogs')
319 ->append('#assembly_rows', $html)
320 ->run('kivi.Part.renumber_positions')
321 ->run('kivi.Part.assembly_recalc')
325 sub action_add_assortment_item {
326 my ($self, %params) = @_;
328 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
330 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
332 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
333 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
334 return $self->js->flash('error', t8("This part has already been added."))->render;
337 my $number_of_items = scalar @{$self->assortment_items};
338 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
339 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
341 push(@{$self->assortment_items}, @{$item_objects});
342 my $part = SL::DB::Part->new(part_type => 'assortment');
343 $part->assortment_items(@{$self->assortment_items});
344 my $items_sellprice_sum = $part->items_sellprice_sum;
345 my $items_lastcost_sum = $part->items_lastcost_sum;
346 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
349 ->append('#assortment_rows' , $html) # append in tbody
350 ->val('.add_assortment_item_input' , '')
351 ->run('kivi.Part.focus_last_assortment_input')
352 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
353 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
354 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
355 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
356 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
360 sub action_add_assembly_item {
363 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
365 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
367 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
369 my $duplicate_warning = 0; # duplicates are allowed, just warn
370 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
371 $duplicate_warning++;
374 my $number_of_items = scalar @{$self->assembly_items};
375 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
377 foreach my $item (@{$item_objects}) {
378 my $errstr = validate_assembly($item->part,$self->part);
379 return $self->js->flash('error',$errstr)->render if $errstr;
384 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
386 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
388 push(@{$self->assembly_items}, @{$item_objects});
389 my $part = SL::DB::Part->new(part_type => 'assembly');
390 $part->assemblies(@{$self->assembly_items});
391 my $items_sellprice_sum = $part->items_sellprice_sum;
392 my $items_lastcost_sum = $part->items_lastcost_sum;
393 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
394 my $items_weight_sum = $part->items_weight_sum;
397 ->append('#assembly_rows', $html) # append in tbody
398 ->val('.add_assembly_item_input' , '')
399 ->run('kivi.Part.focus_last_assembly_input')
400 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
401 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
402 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
403 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
404 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
405 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
409 sub action_show_multi_items_dialog {
412 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
413 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
414 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
416 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
417 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
418 search_term => $search_term
422 sub action_multi_items_update_result {
423 my $max_count = $::form->{limit};
425 my $count = $_[0]->multi_items_models->count;
428 my $text = escape($::locale->text('No results.'));
429 $_[0]->render($text, { layout => 0 });
430 } elsif ($max_count && $count > $max_count) {
431 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
432 $_[0]->render($text, { layout => 0 });
434 my $multi_items = $_[0]->multi_items_models->get;
435 $_[0]->render('part/_multi_items_result', { layout => 0 },
436 multi_items => $multi_items);
440 sub action_add_makemodel_row {
443 my $vendor_id = $::form->{add_makemodel};
445 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
446 return $self->js->error(t8("No vendor selected or found!"))->render;
448 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
449 $self->js->flash('info', t8("This vendor has already been added."));
452 my $position = scalar @{$self->makemodels} + 1;
454 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
458 sortorder => $position,
459 ) or die "Can't create MakeModel object";
461 my $row_as_html = $self->p->render('part/_makemodel_row',
463 listrow => $position % 2 ? 0 : 1,
466 # after selection focus on the model field in the row that was just added
468 ->append('#makemodel_rows', $row_as_html) # append in tbody
469 ->val('.add_makemodel_input', '')
470 ->run('kivi.Part.focus_last_makemodel_input')
474 sub action_add_customerprice_row {
477 my $customer_id = $::form->{add_customerprice};
479 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
480 or return $self->js->error(t8("No customer selected or found!"))->render;
482 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
483 $self->js->flash('info', t8("This customer has already been added."));
486 my $position = scalar @{ $self->customerprices } + 1;
488 my $cu = SL::DB::PartCustomerPrice->new(
489 customer_id => $customer->id,
490 customer_partnumber => '',
492 sortorder => $position,
493 ) or die "Can't create Customerprice object";
495 my $row_as_html = $self->p->render(
496 'part/_customerprice_row',
497 customerprice => $cu,
498 listrow => $position % 2 ? 0
502 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
503 ->val('.add_customerprice_input', '')
504 ->run('kivi.Part.focus_last_customerprice_input')->render;
507 sub action_reorder_items {
510 my $part_type = $::form->{part_type};
513 partnumber => sub { $_[0]->part->partnumber },
514 description => sub { $_[0]->part->description },
515 qty => sub { $_[0]->qty },
516 sellprice => sub { $_[0]->part->sellprice },
517 lastcost => sub { $_[0]->part->lastcost },
518 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
521 my $method = $sort_keys{$::form->{order_by}};
524 if ($part_type eq 'assortment') {
525 @items = @{ $self->assortment_items };
527 @items = @{ $self->assembly_items };
530 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
531 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
532 if ($::form->{sort_dir}) {
533 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
535 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
538 if ($::form->{sort_dir}) {
539 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
541 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
545 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
548 sub action_warehouse_changed {
551 if ($::form->{warehouse_id} ) {
552 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
553 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
555 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
556 $self->bin($self->warehouse->bins_sorted->[0]);
558 ->html('#bin', $self->build_bin_select)
559 ->focus('#part_bin_id');
560 return $self->js->render;
564 # no warehouse was selected, empty the bin field and reset the id
566 ->val('#part_bin_id', undef)
569 return $self->js->render;
572 sub action_ajax_autocomplete {
573 my ($self, %params) = @_;
575 # if someone types something, and hits enter, assume he entered the full name.
576 # if something matches, treat that as sole match
577 # since we need a second get models instance with different filters for that,
578 # we only modify the original filter temporarily in place
579 if ($::form->{prefer_exact}) {
580 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
581 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
582 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
584 my $exact_models = SL::Controller::Helper::GetModels->new(
587 paginated => { per_page => 2 },
588 with_objects => [ qw(unit_obj classification) ],
591 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
592 $self->parts($exact_matches);
598 value => $_->displayable_name,
599 label => $_->displayable_name,
601 partnumber => $_->partnumber,
602 description => $_->description,
604 part_type => $_->part_type,
606 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
608 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
610 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
613 sub action_test_page {
614 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
617 sub action_part_picker_search {
620 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
621 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
622 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
624 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
627 sub action_part_picker_result {
628 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
634 if ($::request->type eq 'json') {
639 $part_hash = $self->part->as_tree;
640 $part_hash->{cvars} = $self->part->cvar_as_hashref;
643 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
648 sub validate_add_items {
649 scalar @{$::form->{add_items}};
652 sub prepare_assortment_render_vars {
655 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
656 items_lastcost_sum => $self->part->items_lastcost_sum,
657 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
659 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
664 sub prepare_assembly_render_vars {
667 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
669 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
670 items_lastcost_sum => $self->part->items_lastcost_sum,
671 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
673 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
681 check_has_valid_part_type($self->part->part_type);
683 $self->_set_javascript;
684 $self->_setup_form_action_bar;
686 my %title_hash = ( part => t8('Add Part'),
687 assembly => t8('Add Assembly'),
688 service => t8('Add Service'),
689 assortment => t8('Add Assortment'),
694 title => $title_hash{$self->part->part_type},
699 sub _set_javascript {
701 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
702 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
705 sub recalc_item_totals {
706 my ($self, %params) = @_;
708 if ( $params{part_type} eq 'assortment' ) {
709 return 0 unless scalar @{$self->assortment_items};
710 } elsif ( $params{part_type} eq 'assembly' ) {
711 return 0 unless scalar @{$self->assembly_items};
713 carp "can only calculate sum for assortments and assemblies";
716 my $part = SL::DB::Part->new(part_type => $params{part_type});
717 if ( $part->is_assortment ) {
718 $part->assortment_items( @{$self->assortment_items} );
719 if ( $params{price_type} eq 'lastcost' ) {
720 return $part->items_lastcost_sum;
722 if ( $params{pricegroup_id} ) {
723 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
725 return $part->items_sellprice_sum;
728 } elsif ( $part->is_assembly ) {
729 $part->assemblies( @{$self->assembly_items} );
730 if ( $params{price_type} eq 'weight' ) {
731 return $part->items_weight_sum;
732 } elsif ( $params{price_type} eq 'lastcost' ) {
733 return $part->items_lastcost_sum;
735 return $part->items_sellprice_sum;
740 sub check_part_not_modified {
743 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
750 my $is_new = !$self->part->id;
752 my $params = delete($::form->{part}) || { };
754 delete $params->{id};
755 $self->part->assign_attributes(%{ $params});
756 $self->part->bin_id(undef) unless $self->part->warehouse_id;
758 $self->normalize_text_blocks;
760 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
761 # will be the case for used assortments when saving, or when a used assortment
763 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
764 $self->part->assortment_items([]);
765 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
768 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
769 $self->part->assemblies([]); # completely rewrite assortments each time
770 $self->part->add_assemblies( @{ $self->assembly_items } );
773 $self->part->translations([]);
774 $self->parse_form_translations;
776 $self->part->prices([]);
777 $self->parse_form_prices;
779 $self->parse_form_customerprices;
780 $self->parse_form_makemodels;
783 sub parse_form_prices {
785 # only save prices > 0
786 my $prices = delete($::form->{prices}) || [];
787 foreach my $price ( @{$prices} ) {
788 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
789 next unless $sellprice > 0; # skip negative prices as well
790 my $p = SL::DB::Price->new(parts_id => $self->part->id,
791 pricegroup_id => $price->{pricegroup_id},
794 $self->part->add_prices($p);
798 sub parse_form_translations {
800 # don't add empty translations
801 my $translations = delete($::form->{translations}) || [];
802 foreach my $translation ( @{$translations} ) {
803 next unless $translation->{translation};
804 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
805 $self->part->add_translations( $translation );
809 sub parse_form_makemodels {
813 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
814 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
817 $self->part->makemodels([]);
820 my $makemodels = delete($::form->{makemodels}) || [];
821 foreach my $makemodel ( @{$makemodels} ) {
822 next unless $makemodel->{make};
824 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
826 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
827 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
829 make => $makemodel->{make},
830 model => $makemodel->{model} || '',
831 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
832 sortorder => $position,
834 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
835 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
836 # don't change lastupdate
837 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
838 # new makemodel, no lastcost entered, leave lastupdate empty
839 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
840 # lastcost hasn't changed, use original lastupdate
841 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
843 $mm->lastupdate(DateTime->now);
845 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
846 $self->part->add_makemodels($mm);
850 sub parse_form_customerprices {
853 my $customerprices_map;
854 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
855 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
858 $self->part->customerprices([]);
861 my $customerprices = delete($::form->{customerprices}) || [];
862 foreach my $customerprice ( @{$customerprices} ) {
863 next unless $customerprice->{customer_id};
865 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
867 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
868 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
870 customer_id => $customerprice->{customer_id},
871 customer_partnumber => $customerprice->{customer_partnumber} || '',
872 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
873 sortorder => $position,
875 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
876 # lastupdate isn't set, original price is 0 and new lastcost is 0
877 # don't change lastupdate
878 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
879 # new customerprice, no lastcost entered, leave lastupdate empty
880 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
881 # price hasn't changed, use original lastupdate
882 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
884 $cu->lastupdate(DateTime->now);
886 $self->part->add_customerprices($cu);
890 sub build_bin_select {
891 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
892 title_key => 'description',
893 default => $_[0]->bin->id,
898 # get_set_inits for partpicker
901 if ($::form->{no_paginate}) {
902 $_[0]->models->disable_plugin('paginated');
908 # get_set_inits for part controller
912 # used by edit, save, delete and add
914 if ( $::form->{part}{id} ) {
915 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
916 } elsif ( $::form->{id} ) {
917 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
919 die "part_type missing" unless $::form->{part}{part_type};
920 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
926 return $self->part->orphaned;
932 SL::Controller::Helper::GetModels->new(
939 partnumber => t8('Partnumber'),
940 description => t8('Description'),
942 with_objects => [ qw(unit_obj classification) ],
951 sub init_assortment_items {
952 # this init is used while saving and whenever assortments change dynamically
956 my $assortment_items = delete($::form->{assortment_items}) || [];
957 foreach my $assortment_item ( @{$assortment_items} ) {
958 next unless $assortment_item->{parts_id};
960 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
961 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
962 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
963 charge => $assortment_item->{charge},
964 unit => $assortment_item->{unit} || $part->unit,
965 position => $position,
973 sub init_makemodels {
977 my @makemodel_array = ();
978 my $makemodels = delete($::form->{makemodels}) || [];
980 foreach my $makemodel ( @{$makemodels} ) {
981 next unless $makemodel->{make};
983 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
984 id => $makemodel->{id},
985 make => $makemodel->{make},
986 model => $makemodel->{model} || '',
987 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
988 sortorder => $position,
989 ) or die "Can't create mm";
990 # $mm->id($makemodel->{id}) if $makemodel->{id};
991 push(@makemodel_array, $mm);
993 return \@makemodel_array;
996 sub init_customerprices {
1000 my @customerprice_array = ();
1001 my $customerprices = delete($::form->{customerprices}) || [];
1003 foreach my $customerprice ( @{$customerprices} ) {
1004 next unless $customerprice->{customer_id};
1006 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1007 id => $customerprice->{id},
1008 customer_partnumber => $customerprice->{customer_partnumber},
1009 customer_id => $customerprice->{customer_id} || '',
1010 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1011 sortorder => $position,
1012 ) or die "Can't create cu";
1013 # $cu->id($customerprice->{id}) if $customerprice->{id};
1014 push(@customerprice_array, $cu);
1016 return \@customerprice_array;
1019 sub init_assembly_items {
1023 my $assembly_items = delete($::form->{assembly_items}) || [];
1024 foreach my $assembly_item ( @{$assembly_items} ) {
1025 next unless $assembly_item->{parts_id};
1027 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1028 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1029 bom => $assembly_item->{bom},
1030 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1031 position => $position,
1038 sub init_all_warehouses {
1040 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1043 sub init_all_languages {
1044 SL::DB::Manager::Language->get_all_sorted;
1047 sub init_all_partsgroups {
1049 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1052 sub init_all_buchungsgruppen {
1054 if ( $self->part->orphaned ) {
1055 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1057 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1061 sub init_shops_not_assigned {
1064 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1065 if ( @used_shop_ids ) {
1066 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1069 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1073 sub init_all_units {
1075 if ( $self->part->orphaned ) {
1076 return SL::DB::Manager::Unit->get_all_sorted;
1078 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1082 sub init_all_payment_terms {
1084 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1087 sub init_all_price_factors {
1088 SL::DB::Manager::PriceFactor->get_all_sorted;
1091 sub init_all_pricegroups {
1092 SL::DB::Manager::Pricegroup->get_all_sorted;
1095 # model used to filter/display the parts in the multi-items dialog
1096 sub init_multi_items_models {
1097 SL::Controller::Helper::GetModels->new(
1098 controller => $_[0],
1100 with_objects => [ qw(unit_obj partsgroup classification) ],
1101 disable_plugin => 'paginated',
1102 source => $::form->{multi_items},
1108 partnumber => t8('Partnumber'),
1109 description => t8('Description')}
1113 sub init_parts_classification_filter {
1114 return [] unless $::form->{parts_classification_type};
1116 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1117 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1119 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1122 # simple checks to run on $::form before saving
1124 sub form_check_part_description_exists {
1127 return 1 if $::form->{part}{description};
1129 $self->js->flash('error', t8('Part Description missing!'))
1130 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1131 ->focus('#part_description');
1135 sub form_check_assortment_items_exist {
1138 return 1 unless $::form->{part}{part_type} eq 'assortment';
1139 # skip item check for existing assortments that have been used
1140 return 1 if ($self->part->id and !$self->part->orphaned);
1142 # new or orphaned parts must have items in $::form->{assortment_items}
1143 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1144 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1145 ->focus('#add_assortment_item_name')
1146 ->flash('error', t8('The assortment doesn\'t have any items.'));
1152 sub form_check_assortment_items_unique {
1155 return 1 unless $::form->{part}{part_type} eq 'assortment';
1157 my %duplicate_elements;
1159 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1160 $duplicate_elements{$_}++ if $count{$_}++;
1163 if ( keys %duplicate_elements ) {
1164 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1165 ->flash('error', t8('There are duplicate assortment items'));
1171 sub form_check_assembly_items_exist {
1174 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1176 # skip item check for existing assembly that have been used
1177 return 1 if ($self->part->id and !$self->part->orphaned);
1179 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1180 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1181 ->focus('#add_assembly_item_name')
1182 ->flash('error', t8('The assembly doesn\'t have any items.'));
1188 sub form_check_partnumber_is_unique {
1191 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1192 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1194 $self->js->flash('error', t8('The partnumber already exists!'))
1195 ->focus('#part_description');
1202 # general checking functions
1205 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1211 $self->form_check_part_description_exists || return 0;
1212 $self->form_check_assortment_items_exist || return 0;
1213 $self->form_check_assortment_items_unique || return 0;
1214 $self->form_check_assembly_items_exist || return 0;
1215 $self->form_check_partnumber_is_unique || return 0;
1220 sub check_has_valid_part_type {
1221 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1225 sub normalize_text_blocks {
1228 # check if feature is enabled (select normalize_part_descriptions from defaults)
1229 return unless ($::instance_conf->get_normalize_part_descriptions);
1232 foreach (qw(description)) {
1233 $self->part->{$_} =~ s/\s+$//s;
1234 $self->part->{$_} =~ s/^\s+//s;
1235 $self->part->{$_} =~ s/ {2,}/ /g;
1237 # html block (caveat: can be circumvented by using bold or italics)
1238 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1239 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1243 sub render_assortment_items_to_html {
1244 my ($self, $assortment_items, $number_of_items) = @_;
1246 my $position = $number_of_items + 1;
1248 foreach my $ai (@$assortment_items) {
1249 $html .= $self->p->render('part/_assortment_row',
1250 PART => $self->part,
1251 orphaned => $self->orphaned,
1253 listrow => $position % 2 ? 1 : 0,
1254 position => $position, # for legacy assemblies
1261 sub render_assembly_items_to_html {
1262 my ($self, $assembly_items, $number_of_items) = @_;
1264 my $position = $number_of_items + 1;
1266 foreach my $ai (@{$assembly_items}) {
1267 $html .= $self->p->render('part/_assembly_row',
1268 PART => $self->part,
1269 orphaned => $self->orphaned,
1271 listrow => $position % 2 ? 1 : 0,
1272 position => $position, # for legacy assemblies
1279 sub parse_add_items_to_objects {
1280 my ($self, %params) = @_;
1281 my $part_type = $params{part_type};
1282 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1283 my $position = $params{position} || 1;
1285 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1288 foreach my $item ( @add_items ) {
1289 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1291 if ( $part_type eq 'assortment' ) {
1292 $ai = SL::DB::AssortmentItem->new(part => $part,
1293 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1294 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1295 position => $position,
1296 ) or die "Can't create AssortmentItem from item";
1297 } elsif ( $part_type eq 'assembly' ) {
1298 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1299 # id => $self->assembly->id, # will be set on save
1300 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1301 bom => 0, # default when adding: no bom
1302 position => $position,
1305 die "part_type must be assortment or assembly";
1307 push(@item_objects, $ai);
1311 return \@item_objects;
1314 sub _setup_form_action_bar {
1317 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1318 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1320 for my $bar ($::request->layout->get('actionbar')) {
1325 call => [ 'kivi.Part.save' ],
1326 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1330 call => [ 'kivi.Part.use_as_new' ],
1331 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1332 : !$may_edit ? t8('You do not have the permissions to access this function.')
1335 ], # end of combobox "Save"
1339 call => [ 'kivi.Part.delete' ],
1340 confirm => t8('Do you really want to delete this object?'),
1341 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1342 : !$may_edit ? t8('You do not have the permissions to access this function.')
1343 : !$self->part->orphaned ? t8('This object has already been used.')
1344 : $used_in_pricerules ? t8('This object is used in price rules.')
1352 call => [ 'kivi.Part.open_history_popup' ],
1353 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1354 : !$may_edit ? t8('You do not have the permissions to access this function.')
1369 SL::Controller::Part - Part CRUD controller
1373 Controller for adding/editing/saving/deleting parts.
1375 All the relations are loaded at once and saving the part, adding a history
1376 entry and saving CVars happens inside one transaction. When saving the old
1377 relations are deleted and written as new to the database.
1379 Relations for parts:
1387 =item assembly items
1389 =item assortment items
1397 There are 4 different part types:
1403 The "default" part type.
1405 inventory_accno_id is set.
1409 Services can't be stocked.
1411 inventory_accno_id isn't set.
1415 Assemblies consist of other parts, services, assemblies or assortments. They
1416 aren't meant to be bought, only sold. To add assemblies to stock you typically
1417 have to make them, which reduces the stock by its respective components. Once
1418 an assembly item has been created there is currently no way to "disassemble" it
1419 again. An assembly item can appear several times in one assembly. An assmbly is
1420 sold as one item with a defined sellprice and lastcost. If the component prices
1421 change the assortment price remains the same. The assembly items may be printed
1422 in a record if the item's "bom" is set.
1426 Similar to assembly, but each assortment item may only appear once per
1427 assortment. When selling an assortment the assortment items are added to the
1428 record together with the assortment, which is added with sellprice 0.
1430 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1431 determined by the sum of the current assortment item prices when the assortment
1432 is added to a record. This also means that price rules and customer discounts
1433 will be applied to the assortment items.
1435 Once the assortment items have been added they may be modified or deleted, just
1436 as if they had been added manually, the individual assortment items aren't
1437 linked to the assortment or the other assortment items in any way.
1445 =item C<action_add_part>
1447 =item C<action_add_service>
1449 =item C<action_add_assembly>
1451 =item C<action_add_assortment>
1453 =item C<action_add PART_TYPE>
1455 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1456 parameter part_type as an action. Example:
1458 controller.pl?action=Part/add&part_type=service
1460 =item C<action_add_from_record>
1462 When adding new items to records they can be created on the fly if the entered
1463 partnumber or description doesn't exist yet. After being asked what part type
1464 the new item should have the user is redirected to the correct edit page.
1466 Depending on whether the item was added from a sales or a purchase record, only
1467 the relevant part classifications should be selectable for new item, so this
1468 parameter is passed on via a hidden parts_classification_type in the new_item
1471 =item C<action_save>
1473 Saves the current part and then reloads the edit page for the part.
1475 =item C<action_use_as_new>
1477 Takes the information from the current part, plus any modifications made on the
1478 page, and creates a new edit page that is ready to be saved. The partnumber is
1479 set empty, so a new partnumber from the number range will be used if the user
1480 doesn't enter one manually.
1482 Unsaved changes to the original part aren't updated.
1484 The part type cannot be changed in this way.
1486 =item C<action_delete>
1488 Deletes the current part and then redirects to the main page, there is no
1491 The delete button only appears if the part is 'orphaned', according to
1492 SL::DB::Part orphaned.
1494 The part can't be deleted if it appears in invoices, orders, delivery orders,
1495 the inventory, or is part of an assembly or assortment.
1497 If the part is deleted its relations prices, makdemodel, assembly,
1498 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1500 Before this controller items that appeared in inventory didn't count as
1501 orphaned and could be deleted and the inventory entries were also deleted, this
1502 "feature" hasn't been implemented.
1504 =item C<action_edit part.id>
1506 Load and display a part for editing.
1508 controller.pl?action=Part/edit&part.id=12345
1510 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1514 =head1 BUTTON ACTIONS
1520 Opens a popup displaying all the history entries. Once a new history controller
1521 is written the button could link there instead, with the part already selected.
1529 =item C<action_update_item_totals>
1531 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1532 amount of an item changes. The sum of all sellprices and lastcosts is
1533 calculated and the totals updated. Uses C<recalc_item_totals>.
1535 =item C<action_add_assortment_item>
1537 Adds a new assortment item from a part picker seleciton to the assortment item list
1539 If the item already exists in the assortment the item isn't added and a Flash
1542 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1543 after adding each new item, add the new object to the item objects that were
1544 already parsed, calculate totals via a dummy part then update the row and the
1547 =item C<action_add_assembly_item>
1549 Adds a new assembly item from a part picker seleciton to the assembly item list
1551 If the item already exists in the assembly a flash info is generated, but the
1554 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1555 after adding each new item, add the new object to the item objects that were
1556 already parsed, calculate totals via a dummy part then update the row and the
1559 =item C<action_add_multi_assortment_items>
1561 Parses the items to be added from the form generated by the multi input and
1562 appends the html of the tr-rows to the assortment item table. Afterwards all
1563 assortment items are renumbered and the sums recalculated via
1564 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1566 =item C<action_add_multi_assembly_items>
1568 Parses the items to be added from the form generated by the multi input and
1569 appends the html of the tr-rows to the assembly item table. Afterwards all
1570 assembly items are renumbered and the sums recalculated via
1571 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1573 =item C<action_show_multi_items_dialog>
1575 =item C<action_multi_items_update_result>
1577 =item C<action_add_makemodel_row>
1579 Add a new makemodel row with the vendor that was selected via the vendor
1582 Checks the already existing makemodels and warns if a row with that vendor
1583 already exists. Currently it is possible to have duplicate vendor rows.
1585 =item C<action_reorder_items>
1587 Sorts the item table for assembly or assortment items.
1589 =item C<action_warehouse_changed>
1593 =head1 ACTIONS part picker
1597 =item C<action_ajax_autocomplete>
1599 =item C<action_test_page>
1601 =item C<action_part_picker_search>
1603 =item C<action_part_picker_result>
1605 =item C<action_show>
1615 Calls some simple checks that test the submitted $::form for obvious errors.
1616 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1618 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1619 some cases extra actions are taken, e.g. if the part description is missing the
1620 basic data tab is selected and the description input field is focussed.
1626 =item C<form_check_part_description_exists>
1628 =item C<form_check_assortment_items_exist>
1630 =item C<form_check_assortment_items_unique>
1632 =item C<form_check_assembly_items_exist>
1634 =item C<form_check_partnumber_is_unique>
1638 =head1 HELPER FUNCTIONS
1644 When submitting the form for saving, parses the transmitted form. Expects the
1648 $::form->{makemodels}
1649 $::form->{translations}
1651 $::form->{assemblies}
1652 $::form->{assortments}
1654 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1656 =item C<recalc_item_totals %params>
1658 Helper function for calculating the total lastcost and sellprice for assemblies
1659 or assortments according to their items, which are parsed from the current
1662 Is called whenever the qty of an item is changed or items are deleted.
1666 * part_type : 'assortment' or 'assembly' (mandatory)
1668 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1670 Depending on the price_type the lastcost sum or sellprice sum is returned.
1672 Doesn't work for recursive items.
1676 =head1 GET SET INITS
1678 There are get_set_inits for
1686 which parse $::form and automatically create an array of objects.
1688 These inits are used during saving and each time a new element is added.
1692 =item C<init_makemodels>
1694 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1695 $self->part->makemodels, ready to be saved.
1697 Used for saving parts and adding new makemodel rows.
1699 =item C<parse_add_items_to_objects PART_TYPE>
1701 Parses the resulting form from either the part-picker submit or the multi-item
1702 submit, and creates an arrayref of assortment_item or assembly objects, that
1703 can be rendered via C<render_assortment_items_to_html> or
1704 C<render_assembly_items_to_html>.
1706 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1707 Optional param: position (used for numbering and listrow class)
1709 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1711 Takes an array_ref of assortment_items, and generates tables rows ready for
1712 adding to the assortment table. Is used when a part is loaded, or whenever new
1713 assortment items are added.
1715 =item C<parse_form_makemodels>
1717 Makemodels can't just be overwritten, because of the field "lastupdate", that
1718 remembers when the lastcost for that vendor changed the last time.
1720 So the original values are cloned and remembered, so we can compare if lastcost
1721 was changed in $::form, and keep or update lastupdate.
1723 lastcost isn't updated until the first time it was saved with a value, until
1726 Also a boolean "makemodel" needs to be written in parts, depending on whether
1727 makemodel entries exist or not.
1729 We still need init_makemodels for when we open the part for editing.
1739 It should be possible to jump to the edit page in a specific tab
1743 Support callbacks, e.g. creating a new part from within an order, and jumping
1744 back to the order again afterwards.
1748 Support units when adding assembly items or assortment items. Currently the
1749 default unit of the item is always used.
1753 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1754 consists of other assemblies.
1760 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>