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(sub { $::auth->assert('developer') },
45 only => [ qw(test_page) ]);
47 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
49 # actions for editing parts
52 my ($self, %params) = @_;
54 $self->part( SL::DB::Part->new_part );
58 sub action_add_service {
59 my ($self, %params) = @_;
61 $self->part( SL::DB::Part->new_service );
65 sub action_add_assembly {
66 my ($self, %params) = @_;
68 $self->part( SL::DB::Part->new_assembly );
72 sub action_add_assortment {
73 my ($self, %params) = @_;
75 $self->part( SL::DB::Part->new_assortment );
79 sub action_add_from_record {
82 check_has_valid_part_type($::form->{part}{part_type});
84 die 'parts_classification_type must be "sales" or "purchases"'
85 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
94 check_has_valid_part_type($::form->{part_type});
96 $self->action_add_part if $::form->{part_type} eq 'part';
97 $self->action_add_service if $::form->{part_type} eq 'service';
98 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
99 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
103 my ($self, %params) = @_;
105 # checks that depend only on submitted $::form
106 $self->check_form or return $self->js->render;
108 my $is_new = !$self->part->id; # $ part gets loaded here
110 # check that the part hasn't been modified
112 $self->check_part_not_modified or
113 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;
117 && $::form->{part}{partnumber}
118 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
120 return $self->js->error(t8('The partnumber is already being used'))->render;
125 my @errors = $self->part->validate;
126 return $self->js->error(@errors)->render if @errors;
128 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
129 $self->part->db->with_transaction(sub {
131 $self->part->save(cascade => 1);
133 SL::DB::History->new(
134 trans_id => $self->part->id,
135 snumbers => 'partnumber_' . $self->part->partnumber,
136 employee_id => SL::DB::Manager::Employee->current->id,
141 CVar->save_custom_variables(
142 dbh => $self->part->db->dbh,
144 trans_id => $self->part->id,
145 variables => $::form, # $::form->{cvar} would be nicer
150 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
152 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
154 if ( $::form->{callback} ) {
155 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
158 # default behaviour after save: reload item, this also resets last_modification!
159 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
163 sub action_save_and_purchase_order {
167 if (1 == scalar @{$self->part->makemodels}) {
168 my $prepared_form = Form->new('');
169 $prepared_form->{vendor_id} = $self->part->makemodels->[0]->make;
170 $session_value = $::auth->save_form_in_session(form => $prepared_form);
173 $::form->{callback} = $self->url_for(
174 controller => 'Order',
175 action => 'return_from_create_part',
176 type => 'purchase_order',
177 previousform => $session_value,
180 $self->_run_action('save');
186 if ( $::form->{callback} ) {
187 $self->redirect_to($::form->unescape($::form->{callback}));
194 my $db = $self->part->db; # $self->part has a get_set_init on $::form
196 my $partnumber = $self->part->partnumber; # remember for history log
201 # delete part, together with relationships that don't already
202 # have an ON DELETE CASCADE, e.g. makemodel and translation.
203 $self->part->delete(cascade => 1);
205 SL::DB::History->new(
206 trans_id => $self->part->id,
207 snumbers => 'partnumber_' . $partnumber,
208 employee_id => SL::DB::Manager::Employee->current->id,
210 addition => 'DELETED',
213 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
215 flash_later('info', t8('The item has been deleted.'));
216 if ( $::form->{callback} ) {
217 $self->redirect_to($::form->unescape($::form->{callback}));
219 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
223 sub action_use_as_new {
224 my ($self, %params) = @_;
226 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
227 $::form->{oldpartnumber} = $oldpart->partnumber;
229 $self->part($oldpart->clone_and_reset_deep);
231 $self->part->partnumber(undef);
237 my ($self, %params) = @_;
243 my ($self, %params) = @_;
245 $self->_set_javascript;
246 $self->_setup_form_action_bar;
248 my (%assortment_vars, %assembly_vars);
249 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
250 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
252 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
254 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
255 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
256 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
257 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
260 my %title_hash = ( part => t8('Edit Part'),
261 assembly => t8('Edit Assembly'),
262 service => t8('Edit Service'),
263 assortment => t8('Edit Assortment'),
266 $self->part->prices([]) unless $self->part->prices;
267 $self->part->translations([]) unless $self->part->translations;
271 title => $title_hash{$self->part->part_type},
274 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
275 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
276 oldpartnumber => $::form->{oldpartnumber},
277 old_id => $::form->{old_id},
285 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
286 $_[0]->render('part/history', { layout => 0 },
287 history_entries => $history_entries);
290 sub action_inventory {
293 $::auth->assert('warehouse_contents');
295 $self->stock_amounts($self->part->get_simple_stock_sql);
296 $self->journal($self->part->get_mini_journal);
298 $_[0]->render('part/_inventory_data', { layout => 0 });
301 sub action_update_item_totals {
304 my $part_type = $::form->{part_type};
305 die unless $part_type =~ /^(assortment|assembly)$/;
307 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
308 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
309 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
311 my $sum_diff = $sellprice_sum-$lastcost_sum;
314 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
315 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
316 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
317 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
318 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
319 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
320 ->no_flash_clear->render();
323 sub action_add_multi_assortment_items {
326 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
327 my $html = $self->render_assortment_items_to_html($item_objects);
329 $self->js->run('kivi.Part.close_picker_dialogs')
330 ->append('#assortment_rows', $html)
331 ->run('kivi.Part.renumber_positions')
332 ->run('kivi.Part.assortment_recalc')
336 sub action_add_multi_assembly_items {
339 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
341 foreach my $item (@{$item_objects}) {
342 my $errstr = validate_assembly($item->part,$self->part);
343 $self->js->flash('error',$errstr) if $errstr;
344 push (@checked_objects,$item) unless $errstr;
347 my $html = $self->render_assembly_items_to_html(\@checked_objects);
349 $self->js->run('kivi.Part.close_picker_dialogs')
350 ->append('#assembly_rows', $html)
351 ->run('kivi.Part.renumber_positions')
352 ->run('kivi.Part.assembly_recalc')
356 sub action_add_assortment_item {
357 my ($self, %params) = @_;
359 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
361 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
363 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
364 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
365 return $self->js->flash('error', t8("This part has already been added."))->render;
368 my $number_of_items = scalar @{$self->assortment_items};
369 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
370 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
372 push(@{$self->assortment_items}, @{$item_objects});
373 my $part = SL::DB::Part->new(part_type => 'assortment');
374 $part->assortment_items(@{$self->assortment_items});
375 my $items_sellprice_sum = $part->items_sellprice_sum;
376 my $items_lastcost_sum = $part->items_lastcost_sum;
377 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
380 ->append('#assortment_rows' , $html) # append in tbody
381 ->val('.add_assortment_item_input' , '')
382 ->run('kivi.Part.focus_last_assortment_input')
383 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
384 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
385 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
386 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
387 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
391 sub action_add_assembly_item {
394 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
396 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
398 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
400 my $duplicate_warning = 0; # duplicates are allowed, just warn
401 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
402 $duplicate_warning++;
405 my $number_of_items = scalar @{$self->assembly_items};
406 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
408 foreach my $item (@{$item_objects}) {
409 my $errstr = validate_assembly($item->part,$self->part);
410 return $self->js->flash('error',$errstr)->render if $errstr;
415 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
417 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
419 push(@{$self->assembly_items}, @{$item_objects});
420 my $part = SL::DB::Part->new(part_type => 'assembly');
421 $part->assemblies(@{$self->assembly_items});
422 my $items_sellprice_sum = $part->items_sellprice_sum;
423 my $items_lastcost_sum = $part->items_lastcost_sum;
424 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
425 my $items_weight_sum = $part->items_weight_sum;
428 ->append('#assembly_rows', $html) # append in tbody
429 ->val('.add_assembly_item_input' , '')
430 ->run('kivi.Part.focus_last_assembly_input')
431 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
432 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
433 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
434 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
435 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
436 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
440 sub action_show_multi_items_dialog {
443 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
444 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
445 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
447 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
448 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
449 search_term => $search_term
453 sub action_multi_items_update_result {
454 my $max_count = $::form->{limit};
456 my $count = $_[0]->multi_items_models->count;
459 my $text = escape($::locale->text('No results.'));
460 $_[0]->render($text, { layout => 0 });
461 } elsif ($max_count && $count > $max_count) {
462 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
463 $_[0]->render($text, { layout => 0 });
465 my $multi_items = $_[0]->multi_items_models->get;
466 $_[0]->render('part/_multi_items_result', { layout => 0 },
467 multi_items => $multi_items);
471 sub action_add_makemodel_row {
474 my $vendor_id = $::form->{add_makemodel};
476 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
477 return $self->js->error(t8("No vendor selected or found!"))->render;
479 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
480 $self->js->flash('info', t8("This vendor has already been added."));
483 my $position = scalar @{$self->makemodels} + 1;
485 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
489 sortorder => $position,
490 ) or die "Can't create MakeModel object";
492 my $row_as_html = $self->p->render('part/_makemodel_row',
494 listrow => $position % 2 ? 0 : 1,
497 # after selection focus on the model field in the row that was just added
499 ->append('#makemodel_rows', $row_as_html) # append in tbody
500 ->val('.add_makemodel_input', '')
501 ->run('kivi.Part.focus_last_makemodel_input')
505 sub action_add_customerprice_row {
508 my $customer_id = $::form->{add_customerprice};
510 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
511 or return $self->js->error(t8("No customer selected or found!"))->render;
513 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
514 $self->js->flash('info', t8("This customer has already been added."));
517 my $position = scalar @{ $self->customerprices } + 1;
519 my $cu = SL::DB::PartCustomerPrice->new(
520 customer_id => $customer->id,
521 customer_partnumber => '',
523 sortorder => $position,
524 ) or die "Can't create Customerprice object";
526 my $row_as_html = $self->p->render(
527 'part/_customerprice_row',
528 customerprice => $cu,
529 listrow => $position % 2 ? 0
533 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
534 ->val('.add_customerprice_input', '')
535 ->run('kivi.Part.focus_last_customerprice_input')->render;
538 sub action_reorder_items {
541 my $part_type = $::form->{part_type};
544 partnumber => sub { $_[0]->part->partnumber },
545 description => sub { $_[0]->part->description },
546 qty => sub { $_[0]->qty },
547 sellprice => sub { $_[0]->part->sellprice },
548 lastcost => sub { $_[0]->part->lastcost },
549 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
552 my $method = $sort_keys{$::form->{order_by}};
555 if ($part_type eq 'assortment') {
556 @items = @{ $self->assortment_items };
558 @items = @{ $self->assembly_items };
561 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
562 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
563 if ($::form->{sort_dir}) {
564 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
566 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
569 if ($::form->{sort_dir}) {
570 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
572 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
576 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
579 sub action_warehouse_changed {
582 if ($::form->{warehouse_id} ) {
583 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
584 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
586 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
587 $self->bin($self->warehouse->bins_sorted->[0]);
589 ->html('#bin', $self->build_bin_select)
590 ->focus('#part_bin_id');
591 return $self->js->render;
595 # no warehouse was selected, empty the bin field and reset the id
597 ->val('#part_bin_id', undef)
600 return $self->js->render;
603 sub action_ajax_autocomplete {
604 my ($self, %params) = @_;
606 # if someone types something, and hits enter, assume he entered the full name.
607 # if something matches, treat that as sole match
608 # since we need a second get models instance with different filters for that,
609 # we only modify the original filter temporarily in place
610 if ($::form->{prefer_exact}) {
611 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
612 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
613 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
615 my $exact_models = SL::Controller::Helper::GetModels->new(
618 paginated => { per_page => 2 },
619 with_objects => [ qw(unit_obj classification) ],
622 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
623 $self->parts($exact_matches);
629 value => $_->displayable_name,
630 label => $_->displayable_name,
632 partnumber => $_->partnumber,
633 description => $_->description,
635 part_type => $_->part_type,
637 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
639 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
641 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
644 sub action_test_page {
645 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
648 sub action_part_picker_search {
651 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
652 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
653 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
655 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
658 sub action_part_picker_result {
659 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
665 if ($::request->type eq 'json') {
670 $part_hash = $self->part->as_tree;
671 $part_hash->{cvars} = $self->part->cvar_as_hashref;
674 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
679 sub validate_add_items {
680 scalar @{$::form->{add_items}};
683 sub prepare_assortment_render_vars {
686 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
687 items_lastcost_sum => $self->part->items_lastcost_sum,
688 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
690 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
695 sub prepare_assembly_render_vars {
698 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
700 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
701 items_lastcost_sum => $self->part->items_lastcost_sum,
702 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
704 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
712 check_has_valid_part_type($self->part->part_type);
714 $self->_set_javascript;
715 $self->_setup_form_action_bar;
717 my %title_hash = ( part => t8('Add Part'),
718 assembly => t8('Add Assembly'),
719 service => t8('Add Service'),
720 assortment => t8('Add Assortment'),
725 title => $title_hash{$self->part->part_type},
730 sub _set_javascript {
732 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart kivi.Validator);
733 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
736 sub recalc_item_totals {
737 my ($self, %params) = @_;
739 if ( $params{part_type} eq 'assortment' ) {
740 return 0 unless scalar @{$self->assortment_items};
741 } elsif ( $params{part_type} eq 'assembly' ) {
742 return 0 unless scalar @{$self->assembly_items};
744 carp "can only calculate sum for assortments and assemblies";
747 my $part = SL::DB::Part->new(part_type => $params{part_type});
748 if ( $part->is_assortment ) {
749 $part->assortment_items( @{$self->assortment_items} );
750 if ( $params{price_type} eq 'lastcost' ) {
751 return $part->items_lastcost_sum;
753 if ( $params{pricegroup_id} ) {
754 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
756 return $part->items_sellprice_sum;
759 } elsif ( $part->is_assembly ) {
760 $part->assemblies( @{$self->assembly_items} );
761 if ( $params{price_type} eq 'weight' ) {
762 return $part->items_weight_sum;
763 } elsif ( $params{price_type} eq 'lastcost' ) {
764 return $part->items_lastcost_sum;
766 return $part->items_sellprice_sum;
771 sub check_part_not_modified {
774 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
781 my $is_new = !$self->part->id;
783 my $params = delete($::form->{part}) || { };
785 delete $params->{id};
786 $self->part->assign_attributes(%{ $params});
787 $self->part->bin_id(undef) unless $self->part->warehouse_id;
789 $self->normalize_text_blocks;
791 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
792 # will be the case for used assortments when saving, or when a used assortment
794 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
795 $self->part->assortment_items([]);
796 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
799 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
800 $self->part->assemblies([]); # completely rewrite assortments each time
801 $self->part->add_assemblies( @{ $self->assembly_items } );
804 $self->part->translations([]);
805 $self->parse_form_translations;
807 $self->part->prices([]);
808 $self->parse_form_prices;
810 $self->parse_form_customerprices;
811 $self->parse_form_makemodels;
814 sub parse_form_prices {
816 # only save prices > 0
817 my $prices = delete($::form->{prices}) || [];
818 foreach my $price ( @{$prices} ) {
819 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
820 next unless $sellprice > 0; # skip negative prices as well
821 my $p = SL::DB::Price->new(parts_id => $self->part->id,
822 pricegroup_id => $price->{pricegroup_id},
825 $self->part->add_prices($p);
829 sub parse_form_translations {
831 # don't add empty translations
832 my $translations = delete($::form->{translations}) || [];
833 foreach my $translation ( @{$translations} ) {
834 next unless $translation->{translation};
835 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
836 $self->part->add_translations( $translation );
840 sub parse_form_makemodels {
844 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
845 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
848 $self->part->makemodels([]);
851 my $makemodels = delete($::form->{makemodels}) || [];
852 foreach my $makemodel ( @{$makemodels} ) {
853 next unless $makemodel->{make};
855 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
857 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
858 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
860 make => $makemodel->{make},
861 model => $makemodel->{model} || '',
862 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
863 sortorder => $position,
865 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
866 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
867 # don't change lastupdate
868 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
869 # new makemodel, no lastcost entered, leave lastupdate empty
870 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
871 # lastcost hasn't changed, use original lastupdate
872 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
874 $mm->lastupdate(DateTime->now);
876 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
877 $self->part->add_makemodels($mm);
881 sub parse_form_customerprices {
884 my $customerprices_map;
885 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
886 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
889 $self->part->customerprices([]);
892 my $customerprices = delete($::form->{customerprices}) || [];
893 foreach my $customerprice ( @{$customerprices} ) {
894 next unless $customerprice->{customer_id};
896 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
898 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
899 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
901 customer_id => $customerprice->{customer_id},
902 customer_partnumber => $customerprice->{customer_partnumber} || '',
903 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
904 sortorder => $position,
906 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
907 # lastupdate isn't set, original price is 0 and new lastcost is 0
908 # don't change lastupdate
909 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
910 # new customerprice, no lastcost entered, leave lastupdate empty
911 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
912 # price hasn't changed, use original lastupdate
913 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
915 $cu->lastupdate(DateTime->now);
917 $self->part->add_customerprices($cu);
921 sub build_bin_select {
922 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
923 title_key => 'description',
924 default => $_[0]->bin->id,
929 # get_set_inits for partpicker
932 if ($::form->{no_paginate}) {
933 $_[0]->models->disable_plugin('paginated');
939 # get_set_inits for part controller
943 # used by edit, save, delete and add
945 if ( $::form->{part}{id} ) {
946 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
947 } elsif ( $::form->{id} ) {
948 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
950 die "part_type missing" unless $::form->{part}{part_type};
951 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
957 return $self->part->orphaned;
963 SL::Controller::Helper::GetModels->new(
970 partnumber => t8('Partnumber'),
971 description => t8('Description'),
973 with_objects => [ qw(unit_obj classification) ],
982 sub init_assortment_items {
983 # this init is used while saving and whenever assortments change dynamically
987 my $assortment_items = delete($::form->{assortment_items}) || [];
988 foreach my $assortment_item ( @{$assortment_items} ) {
989 next unless $assortment_item->{parts_id};
991 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
992 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
993 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
994 charge => $assortment_item->{charge},
995 unit => $assortment_item->{unit} || $part->unit,
996 position => $position,
1004 sub init_makemodels {
1008 my @makemodel_array = ();
1009 my $makemodels = delete($::form->{makemodels}) || [];
1011 foreach my $makemodel ( @{$makemodels} ) {
1012 next unless $makemodel->{make};
1014 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
1015 id => $makemodel->{id},
1016 make => $makemodel->{make},
1017 model => $makemodel->{model} || '',
1018 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
1019 sortorder => $position,
1020 ) or die "Can't create mm";
1021 # $mm->id($makemodel->{id}) if $makemodel->{id};
1022 push(@makemodel_array, $mm);
1024 return \@makemodel_array;
1027 sub init_customerprices {
1031 my @customerprice_array = ();
1032 my $customerprices = delete($::form->{customerprices}) || [];
1034 foreach my $customerprice ( @{$customerprices} ) {
1035 next unless $customerprice->{customer_id};
1037 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1038 id => $customerprice->{id},
1039 customer_partnumber => $customerprice->{customer_partnumber},
1040 customer_id => $customerprice->{customer_id} || '',
1041 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1042 sortorder => $position,
1043 ) or die "Can't create cu";
1044 # $cu->id($customerprice->{id}) if $customerprice->{id};
1045 push(@customerprice_array, $cu);
1047 return \@customerprice_array;
1050 sub init_assembly_items {
1054 my $assembly_items = delete($::form->{assembly_items}) || [];
1055 foreach my $assembly_item ( @{$assembly_items} ) {
1056 next unless $assembly_item->{parts_id};
1058 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1059 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1060 bom => $assembly_item->{bom},
1061 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1062 position => $position,
1069 sub init_all_warehouses {
1071 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1074 sub init_all_languages {
1075 SL::DB::Manager::Language->get_all_sorted;
1078 sub init_all_partsgroups {
1080 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1083 sub init_all_buchungsgruppen {
1085 if ( $self->part->orphaned ) {
1086 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1088 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1092 sub init_shops_not_assigned {
1095 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1096 if ( @used_shop_ids ) {
1097 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1100 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1104 sub init_all_units {
1106 if ( $self->part->orphaned ) {
1107 return SL::DB::Manager::Unit->get_all_sorted;
1109 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1113 sub init_all_payment_terms {
1115 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1118 sub init_all_price_factors {
1119 SL::DB::Manager::PriceFactor->get_all_sorted;
1122 sub init_all_pricegroups {
1123 SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]);
1126 # model used to filter/display the parts in the multi-items dialog
1127 sub init_multi_items_models {
1128 SL::Controller::Helper::GetModels->new(
1129 controller => $_[0],
1131 with_objects => [ qw(unit_obj partsgroup classification) ],
1132 disable_plugin => 'paginated',
1133 source => $::form->{multi_items},
1139 partnumber => t8('Partnumber'),
1140 description => t8('Description')}
1144 sub init_parts_classification_filter {
1145 return [] unless $::form->{parts_classification_type};
1147 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1148 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1150 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1153 # simple checks to run on $::form before saving
1155 sub form_check_part_description_exists {
1158 return 1 if $::form->{part}{description};
1160 $self->js->flash('error', t8('Part Description missing!'))
1161 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1162 ->focus('#part_description');
1166 sub form_check_assortment_items_exist {
1169 return 1 unless $::form->{part}{part_type} eq 'assortment';
1170 # skip item check for existing assortments that have been used
1171 return 1 if ($self->part->id and !$self->part->orphaned);
1173 # new or orphaned parts must have items in $::form->{assortment_items}
1174 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1175 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1176 ->focus('#add_assortment_item_name')
1177 ->flash('error', t8('The assortment doesn\'t have any items.'));
1183 sub form_check_assortment_items_unique {
1186 return 1 unless $::form->{part}{part_type} eq 'assortment';
1188 my %duplicate_elements;
1190 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1191 $duplicate_elements{$_}++ if $count{$_}++;
1194 if ( keys %duplicate_elements ) {
1195 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1196 ->flash('error', t8('There are duplicate assortment items'));
1202 sub form_check_assembly_items_exist {
1205 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1207 # skip item check for existing assembly that have been used
1208 return 1 if ($self->part->id and !$self->part->orphaned);
1210 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1211 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1212 ->focus('#add_assembly_item_name')
1213 ->flash('error', t8('The assembly doesn\'t have any items.'));
1219 sub form_check_partnumber_is_unique {
1222 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1223 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1225 $self->js->flash('error', t8('The partnumber already exists!'))
1226 ->focus('#part_description');
1233 # general checking functions
1236 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1242 $self->form_check_part_description_exists || return 0;
1243 $self->form_check_assortment_items_exist || return 0;
1244 $self->form_check_assortment_items_unique || return 0;
1245 $self->form_check_assembly_items_exist || return 0;
1246 $self->form_check_partnumber_is_unique || return 0;
1251 sub check_has_valid_part_type {
1252 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1256 sub normalize_text_blocks {
1259 # check if feature is enabled (select normalize_part_descriptions from defaults)
1260 return unless ($::instance_conf->get_normalize_part_descriptions);
1263 foreach (qw(description)) {
1264 $self->part->{$_} =~ s/\s+$//s;
1265 $self->part->{$_} =~ s/^\s+//s;
1266 $self->part->{$_} =~ s/ {2,}/ /g;
1268 # html block (caveat: can be circumvented by using bold or italics)
1269 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1270 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1274 sub render_assortment_items_to_html {
1275 my ($self, $assortment_items, $number_of_items) = @_;
1277 my $position = $number_of_items + 1;
1279 foreach my $ai (@$assortment_items) {
1280 $html .= $self->p->render('part/_assortment_row',
1281 PART => $self->part,
1282 orphaned => $self->orphaned,
1284 listrow => $position % 2 ? 1 : 0,
1285 position => $position, # for legacy assemblies
1292 sub render_assembly_items_to_html {
1293 my ($self, $assembly_items, $number_of_items) = @_;
1295 my $position = $number_of_items + 1;
1297 foreach my $ai (@{$assembly_items}) {
1298 $html .= $self->p->render('part/_assembly_row',
1299 PART => $self->part,
1300 orphaned => $self->orphaned,
1302 listrow => $position % 2 ? 1 : 0,
1303 position => $position, # for legacy assemblies
1310 sub parse_add_items_to_objects {
1311 my ($self, %params) = @_;
1312 my $part_type = $params{part_type};
1313 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1314 my $position = $params{position} || 1;
1316 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1319 foreach my $item ( @add_items ) {
1320 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1322 if ( $part_type eq 'assortment' ) {
1323 $ai = SL::DB::AssortmentItem->new(part => $part,
1324 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1325 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1326 position => $position,
1327 ) or die "Can't create AssortmentItem from item";
1328 } elsif ( $part_type eq 'assembly' ) {
1329 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1330 # id => $self->assembly->id, # will be set on save
1331 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1332 bom => 0, # default when adding: no bom
1333 position => $position,
1336 die "part_type must be assortment or assembly";
1338 push(@item_objects, $ai);
1342 return \@item_objects;
1345 sub _setup_form_action_bar {
1348 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1349 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1351 for my $bar ($::request->layout->get('actionbar')) {
1356 call => [ 'kivi.Part.save' ],
1357 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1358 checks => ['kivi.validate_form'],
1362 call => [ 'kivi.Part.use_as_new' ],
1363 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1364 : !$may_edit ? t8('You do not have the permissions to access this function.')
1367 ], # end of combobox "Save"
1370 action => [ t8('Workflow') ],
1372 t8('Save and Purchase Order'),
1373 submit => [ '#ic', { action => "Part/save_and_purchase_order" } ],
1374 checks => ['kivi.validate_form'],
1375 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1376 : !$may_edit ? t8('You do not have the permissions to access this function.')
1377 : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.')
1379 only_if => !$::form->{inline_create},
1385 submit => [ '#ic', { action => "Part/abort" } ],
1386 only_if => !!$::form->{inline_create},
1391 call => [ 'kivi.Part.delete' ],
1392 confirm => t8('Do you really want to delete this object?'),
1393 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1394 : !$may_edit ? t8('You do not have the permissions to access this function.')
1395 : !$self->part->orphaned ? t8('This object has already been used.')
1396 : $used_in_pricerules ? t8('This object is used in price rules.')
1404 call => [ 'kivi.Part.open_history_popup' ],
1405 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1406 : !$may_edit ? t8('You do not have the permissions to access this function.')
1421 SL::Controller::Part - Part CRUD controller
1425 Controller for adding/editing/saving/deleting parts.
1427 All the relations are loaded at once and saving the part, adding a history
1428 entry and saving CVars happens inside one transaction. When saving the old
1429 relations are deleted and written as new to the database.
1431 Relations for parts:
1439 =item assembly items
1441 =item assortment items
1449 There are 4 different part types:
1455 The "default" part type.
1457 inventory_accno_id is set.
1461 Services can't be stocked.
1463 inventory_accno_id isn't set.
1467 Assemblies consist of other parts, services, assemblies or assortments. They
1468 aren't meant to be bought, only sold. To add assemblies to stock you typically
1469 have to make them, which reduces the stock by its respective components. Once
1470 an assembly item has been created there is currently no way to "disassemble" it
1471 again. An assembly item can appear several times in one assembly. An assmbly is
1472 sold as one item with a defined sellprice and lastcost. If the component prices
1473 change the assortment price remains the same. The assembly items may be printed
1474 in a record if the item's "bom" is set.
1478 Similar to assembly, but each assortment item may only appear once per
1479 assortment. When selling an assortment the assortment items are added to the
1480 record together with the assortment, which is added with sellprice 0.
1482 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1483 determined by the sum of the current assortment item prices when the assortment
1484 is added to a record. This also means that price rules and customer discounts
1485 will be applied to the assortment items.
1487 Once the assortment items have been added they may be modified or deleted, just
1488 as if they had been added manually, the individual assortment items aren't
1489 linked to the assortment or the other assortment items in any way.
1497 =item C<action_add_part>
1499 =item C<action_add_service>
1501 =item C<action_add_assembly>
1503 =item C<action_add_assortment>
1505 =item C<action_add PART_TYPE>
1507 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1508 parameter part_type as an action. Example:
1510 controller.pl?action=Part/add&part_type=service
1512 =item C<action_add_from_record>
1514 When adding new items to records they can be created on the fly if the entered
1515 partnumber or description doesn't exist yet. After being asked what part type
1516 the new item should have the user is redirected to the correct edit page.
1518 Depending on whether the item was added from a sales or a purchase record, only
1519 the relevant part classifications should be selectable for new item, so this
1520 parameter is passed on via a hidden parts_classification_type in the new_item
1523 =item C<action_save>
1525 Saves the current part and then reloads the edit page for the part.
1527 =item C<action_use_as_new>
1529 Takes the information from the current part, plus any modifications made on the
1530 page, and creates a new edit page that is ready to be saved. The partnumber is
1531 set empty, so a new partnumber from the number range will be used if the user
1532 doesn't enter one manually.
1534 Unsaved changes to the original part aren't updated.
1536 The part type cannot be changed in this way.
1538 =item C<action_delete>
1540 Deletes the current part and then redirects to the main page, there is no
1543 The delete button only appears if the part is 'orphaned', according to
1544 SL::DB::Part orphaned.
1546 The part can't be deleted if it appears in invoices, orders, delivery orders,
1547 the inventory, or is part of an assembly or assortment.
1549 If the part is deleted its relations prices, makdemodel, assembly,
1550 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1552 Before this controller items that appeared in inventory didn't count as
1553 orphaned and could be deleted and the inventory entries were also deleted, this
1554 "feature" hasn't been implemented.
1556 =item C<action_edit part.id>
1558 Load and display a part for editing.
1560 controller.pl?action=Part/edit&part.id=12345
1562 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1566 =head1 BUTTON ACTIONS
1572 Opens a popup displaying all the history entries. Once a new history controller
1573 is written the button could link there instead, with the part already selected.
1581 =item C<action_update_item_totals>
1583 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1584 amount of an item changes. The sum of all sellprices and lastcosts is
1585 calculated and the totals updated. Uses C<recalc_item_totals>.
1587 =item C<action_add_assortment_item>
1589 Adds a new assortment item from a part picker seleciton to the assortment item list
1591 If the item already exists in the assortment the item isn't added and a Flash
1594 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1595 after adding each new item, add the new object to the item objects that were
1596 already parsed, calculate totals via a dummy part then update the row and the
1599 =item C<action_add_assembly_item>
1601 Adds a new assembly item from a part picker seleciton to the assembly item list
1603 If the item already exists in the assembly a flash info is generated, but the
1606 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1607 after adding each new item, add the new object to the item objects that were
1608 already parsed, calculate totals via a dummy part then update the row and the
1611 =item C<action_add_multi_assortment_items>
1613 Parses the items to be added from the form generated by the multi input and
1614 appends the html of the tr-rows to the assortment item table. Afterwards all
1615 assortment items are renumbered and the sums recalculated via
1616 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1618 =item C<action_add_multi_assembly_items>
1620 Parses the items to be added from the form generated by the multi input and
1621 appends the html of the tr-rows to the assembly item table. Afterwards all
1622 assembly items are renumbered and the sums recalculated via
1623 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1625 =item C<action_show_multi_items_dialog>
1627 =item C<action_multi_items_update_result>
1629 =item C<action_add_makemodel_row>
1631 Add a new makemodel row with the vendor that was selected via the vendor
1634 Checks the already existing makemodels and warns if a row with that vendor
1635 already exists. Currently it is possible to have duplicate vendor rows.
1637 =item C<action_reorder_items>
1639 Sorts the item table for assembly or assortment items.
1641 =item C<action_warehouse_changed>
1645 =head1 ACTIONS part picker
1649 =item C<action_ajax_autocomplete>
1651 =item C<action_test_page>
1653 =item C<action_part_picker_search>
1655 =item C<action_part_picker_result>
1657 =item C<action_show>
1667 Calls some simple checks that test the submitted $::form for obvious errors.
1668 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1670 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1671 some cases extra actions are taken, e.g. if the part description is missing the
1672 basic data tab is selected and the description input field is focussed.
1678 =item C<form_check_part_description_exists>
1680 =item C<form_check_assortment_items_exist>
1682 =item C<form_check_assortment_items_unique>
1684 =item C<form_check_assembly_items_exist>
1686 =item C<form_check_partnumber_is_unique>
1690 =head1 HELPER FUNCTIONS
1696 When submitting the form for saving, parses the transmitted form. Expects the
1700 $::form->{makemodels}
1701 $::form->{translations}
1703 $::form->{assemblies}
1704 $::form->{assortments}
1706 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1708 =item C<recalc_item_totals %params>
1710 Helper function for calculating the total lastcost and sellprice for assemblies
1711 or assortments according to their items, which are parsed from the current
1714 Is called whenever the qty of an item is changed or items are deleted.
1718 * part_type : 'assortment' or 'assembly' (mandatory)
1720 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1722 Depending on the price_type the lastcost sum or sellprice sum is returned.
1724 Doesn't work for recursive items.
1728 =head1 GET SET INITS
1730 There are get_set_inits for
1738 which parse $::form and automatically create an array of objects.
1740 These inits are used during saving and each time a new element is added.
1744 =item C<init_makemodels>
1746 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1747 $self->part->makemodels, ready to be saved.
1749 Used for saving parts and adding new makemodel rows.
1751 =item C<parse_add_items_to_objects PART_TYPE>
1753 Parses the resulting form from either the part-picker submit or the multi-item
1754 submit, and creates an arrayref of assortment_item or assembly objects, that
1755 can be rendered via C<render_assortment_items_to_html> or
1756 C<render_assembly_items_to_html>.
1758 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1759 Optional param: position (used for numbering and listrow class)
1761 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1763 Takes an array_ref of assortment_items, and generates tables rows ready for
1764 adding to the assortment table. Is used when a part is loaded, or whenever new
1765 assortment items are added.
1767 =item C<parse_form_makemodels>
1769 Makemodels can't just be overwritten, because of the field "lastupdate", that
1770 remembers when the lastcost for that vendor changed the last time.
1772 So the original values are cloned and remembered, so we can compare if lastcost
1773 was changed in $::form, and keep or update lastupdate.
1775 lastcost isn't updated until the first time it was saved with a value, until
1778 Also a boolean "makemodel" needs to be written in parts, depending on whether
1779 makemodel entries exist or not.
1781 We still need init_makemodels for when we open the part for editing.
1791 It should be possible to jump to the edit page in a specific tab
1795 Support callbacks, e.g. creating a new part from within an order, and jumping
1796 back to the order again afterwards.
1800 Support units when adding assembly items or assortment items. Currently the
1801 default unit of the item is always used.
1805 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1806 consists of other assemblies.
1812 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>