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 if ( $params{save_as_new} ) {
129 $self->part( $self->part->clone_and_reset_deep );
130 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
133 $self->part->save(cascade => 1);
135 SL::DB::History->new(
136 trans_id => $self->part->id,
137 snumbers => 'partnumber_' . $self->part->partnumber,
138 employee_id => SL::DB::Manager::Employee->current->id,
143 CVar->save_custom_variables(
144 dbh => $self->part->db->dbh,
146 trans_id => $self->part->id,
147 variables => $::form, # $::form->{cvar} would be nicer
152 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
154 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
156 if ( $::form->{callback} ) {
157 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
160 # default behaviour after save: reload item, this also resets last_modification!
161 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
165 sub action_save_as_new {
167 $self->action_save(save_as_new=>1);
173 my $db = $self->part->db; # $self->part has a get_set_init on $::form
175 my $partnumber = $self->part->partnumber; # remember for history log
180 # delete part, together with relationships that don't already
181 # have an ON DELETE CASCADE, e.g. makemodel and translation.
182 $self->part->delete(cascade => 1);
184 SL::DB::History->new(
185 trans_id => $self->part->id,
186 snumbers => 'partnumber_' . $partnumber,
187 employee_id => SL::DB::Manager::Employee->current->id,
189 addition => 'DELETED',
192 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
194 flash_later('info', t8('The item has been deleted.'));
195 if ( $::form->{callback} ) {
196 $self->redirect_to($::form->unescape($::form->{callback}));
198 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
202 sub action_use_as_new {
203 my ($self, %params) = @_;
205 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
206 $::form->{oldpartnumber} = $oldpart->partnumber;
208 $self->part($oldpart->clone_and_reset_deep);
210 $self->part->partnumber(undef);
216 my ($self, %params) = @_;
222 my ($self, %params) = @_;
224 $self->_set_javascript;
225 $self->_setup_form_action_bar;
227 my (%assortment_vars, %assembly_vars);
228 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
229 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
231 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
233 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
234 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
235 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
236 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
239 my %title_hash = ( part => t8('Edit Part'),
240 assembly => t8('Edit Assembly'),
241 service => t8('Edit Service'),
242 assortment => t8('Edit Assortment'),
245 $self->part->prices([]) unless $self->part->prices;
246 $self->part->translations([]) unless $self->part->translations;
250 title => $title_hash{$self->part->part_type},
253 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
254 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
255 oldpartnumber => $::form->{oldpartnumber},
256 old_id => $::form->{old_id},
264 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
265 $_[0]->render('part/history', { layout => 0 },
266 history_entries => $history_entries);
269 sub action_inventory {
272 $::auth->assert('warehouse_contents');
274 $self->stock_amounts($self->part->get_simple_stock_sql);
275 $self->journal($self->part->get_mini_journal);
277 $_[0]->render('part/_inventory_data', { layout => 0 });
280 sub action_update_item_totals {
283 my $part_type = $::form->{part_type};
284 die unless $part_type =~ /^(assortment|assembly)$/;
286 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
287 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
288 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
290 my $sum_diff = $sellprice_sum-$lastcost_sum;
293 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
294 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
295 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
296 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
297 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
298 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
299 ->no_flash_clear->render();
302 sub action_add_multi_assortment_items {
305 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
306 my $html = $self->render_assortment_items_to_html($item_objects);
308 $self->js->run('kivi.Part.close_picker_dialogs')
309 ->append('#assortment_rows', $html)
310 ->run('kivi.Part.renumber_positions')
311 ->run('kivi.Part.assortment_recalc')
315 sub action_add_multi_assembly_items {
318 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
320 foreach my $item (@{$item_objects}) {
321 my $errstr = validate_assembly($item->part,$self->part);
322 $self->js->flash('error',$errstr) if $errstr;
323 push (@checked_objects,$item) unless $errstr;
326 my $html = $self->render_assembly_items_to_html(\@checked_objects);
328 $self->js->run('kivi.Part.close_picker_dialogs')
329 ->append('#assembly_rows', $html)
330 ->run('kivi.Part.renumber_positions')
331 ->run('kivi.Part.assembly_recalc')
335 sub action_add_assortment_item {
336 my ($self, %params) = @_;
338 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
340 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
342 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
343 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
344 return $self->js->flash('error', t8("This part has already been added."))->render;
347 my $number_of_items = scalar @{$self->assortment_items};
348 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
349 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
351 push(@{$self->assortment_items}, @{$item_objects});
352 my $part = SL::DB::Part->new(part_type => 'assortment');
353 $part->assortment_items(@{$self->assortment_items});
354 my $items_sellprice_sum = $part->items_sellprice_sum;
355 my $items_lastcost_sum = $part->items_lastcost_sum;
356 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
359 ->append('#assortment_rows' , $html) # append in tbody
360 ->val('.add_assortment_item_input' , '')
361 ->run('kivi.Part.focus_last_assortment_input')
362 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
363 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
364 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
365 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
366 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
370 sub action_add_assembly_item {
373 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
375 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
377 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
379 my $duplicate_warning = 0; # duplicates are allowed, just warn
380 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
381 $duplicate_warning++;
384 my $number_of_items = scalar @{$self->assembly_items};
385 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
387 foreach my $item (@{$item_objects}) {
388 my $errstr = validate_assembly($item->part,$self->part);
389 return $self->js->flash('error',$errstr)->render if $errstr;
394 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
396 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
398 push(@{$self->assembly_items}, @{$item_objects});
399 my $part = SL::DB::Part->new(part_type => 'assembly');
400 $part->assemblies(@{$self->assembly_items});
401 my $items_sellprice_sum = $part->items_sellprice_sum;
402 my $items_lastcost_sum = $part->items_lastcost_sum;
403 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
404 my $items_weight_sum = $part->items_weight_sum;
407 ->append('#assembly_rows', $html) # append in tbody
408 ->val('.add_assembly_item_input' , '')
409 ->run('kivi.Part.focus_last_assembly_input')
410 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
411 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
412 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
413 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
414 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
415 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
419 sub action_show_multi_items_dialog {
422 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
423 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
424 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
426 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
427 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
428 search_term => $search_term
432 sub action_multi_items_update_result {
433 my $max_count = $::form->{limit};
435 my $count = $_[0]->multi_items_models->count;
438 my $text = escape($::locale->text('No results.'));
439 $_[0]->render($text, { layout => 0 });
440 } elsif ($max_count && $count > $max_count) {
441 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
442 $_[0]->render($text, { layout => 0 });
444 my $multi_items = $_[0]->multi_items_models->get;
445 $_[0]->render('part/_multi_items_result', { layout => 0 },
446 multi_items => $multi_items);
450 sub action_add_makemodel_row {
453 my $vendor_id = $::form->{add_makemodel};
455 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
456 return $self->js->error(t8("No vendor selected or found!"))->render;
458 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
459 $self->js->flash('info', t8("This vendor has already been added."));
462 my $position = scalar @{$self->makemodels} + 1;
464 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
468 sortorder => $position,
469 ) or die "Can't create MakeModel object";
471 my $row_as_html = $self->p->render('part/_makemodel_row',
473 listrow => $position % 2 ? 0 : 1,
476 # after selection focus on the model field in the row that was just added
478 ->append('#makemodel_rows', $row_as_html) # append in tbody
479 ->val('.add_makemodel_input', '')
480 ->run('kivi.Part.focus_last_makemodel_input')
484 sub action_add_customerprice_row {
487 my $customer_id = $::form->{add_customerprice};
489 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
490 or return $self->js->error(t8("No customer selected or found!"))->render;
492 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
493 $self->js->flash('info', t8("This customer has already been added."));
496 my $position = scalar @{ $self->customerprices } + 1;
498 my $cu = SL::DB::PartCustomerPrice->new(
499 customer_id => $customer->id,
500 customer_partnumber => '',
502 sortorder => $position,
503 ) or die "Can't create Customerprice object";
505 my $row_as_html = $self->p->render(
506 'part/_customerprice_row',
507 customerprice => $cu,
508 listrow => $position % 2 ? 0
512 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
513 ->val('.add_customerprice_input', '')
514 ->run('kivi.Part.focus_last_customerprice_input')->render;
517 sub action_reorder_items {
520 my $part_type = $::form->{part_type};
523 partnumber => sub { $_[0]->part->partnumber },
524 description => sub { $_[0]->part->description },
525 qty => sub { $_[0]->qty },
526 sellprice => sub { $_[0]->part->sellprice },
527 lastcost => sub { $_[0]->part->lastcost },
528 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
531 my $method = $sort_keys{$::form->{order_by}};
534 if ($part_type eq 'assortment') {
535 @items = @{ $self->assortment_items };
537 @items = @{ $self->assembly_items };
540 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
541 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
542 if ($::form->{sort_dir}) {
543 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
545 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
548 if ($::form->{sort_dir}) {
549 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
551 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
555 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
558 sub action_warehouse_changed {
561 if ($::form->{warehouse_id} ) {
562 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
563 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
565 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
566 $self->bin($self->warehouse->bins_sorted->[0]);
568 ->html('#bin', $self->build_bin_select)
569 ->focus('#part_bin_id');
570 return $self->js->render;
574 # no warehouse was selected, empty the bin field and reset the id
576 ->val('#part_bin_id', undef)
579 return $self->js->render;
582 sub action_ajax_autocomplete {
583 my ($self, %params) = @_;
585 # if someone types something, and hits enter, assume he entered the full name.
586 # if something matches, treat that as sole match
587 # since we need a second get models instance with different filters for that,
588 # we only modify the original filter temporarily in place
589 if ($::form->{prefer_exact}) {
590 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
591 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
592 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
594 my $exact_models = SL::Controller::Helper::GetModels->new(
597 paginated => { per_page => 2 },
598 with_objects => [ qw(unit_obj classification) ],
601 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
602 $self->parts($exact_matches);
608 value => $_->displayable_name,
609 label => $_->displayable_name,
611 partnumber => $_->partnumber,
612 description => $_->description,
614 part_type => $_->part_type,
616 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
618 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
620 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
623 sub action_test_page {
624 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
627 sub action_part_picker_search {
630 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
631 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
632 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
634 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
637 sub action_part_picker_result {
638 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
644 if ($::request->type eq 'json') {
649 $part_hash = $self->part->as_tree;
650 $part_hash->{cvars} = $self->part->cvar_as_hashref;
653 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
658 sub validate_add_items {
659 scalar @{$::form->{add_items}};
662 sub prepare_assortment_render_vars {
665 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
666 items_lastcost_sum => $self->part->items_lastcost_sum,
667 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
669 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
674 sub prepare_assembly_render_vars {
677 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
679 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
680 items_lastcost_sum => $self->part->items_lastcost_sum,
681 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
683 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
691 check_has_valid_part_type($self->part->part_type);
693 $self->_set_javascript;
694 $self->_setup_form_action_bar;
696 my %title_hash = ( part => t8('Add Part'),
697 assembly => t8('Add Assembly'),
698 service => t8('Add Service'),
699 assortment => t8('Add Assortment'),
704 title => $title_hash{$self->part->part_type},
709 sub _set_javascript {
711 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
712 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
715 sub recalc_item_totals {
716 my ($self, %params) = @_;
718 if ( $params{part_type} eq 'assortment' ) {
719 return 0 unless scalar @{$self->assortment_items};
720 } elsif ( $params{part_type} eq 'assembly' ) {
721 return 0 unless scalar @{$self->assembly_items};
723 carp "can only calculate sum for assortments and assemblies";
726 my $part = SL::DB::Part->new(part_type => $params{part_type});
727 if ( $part->is_assortment ) {
728 $part->assortment_items( @{$self->assortment_items} );
729 if ( $params{price_type} eq 'lastcost' ) {
730 return $part->items_lastcost_sum;
732 if ( $params{pricegroup_id} ) {
733 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
735 return $part->items_sellprice_sum;
738 } elsif ( $part->is_assembly ) {
739 $part->assemblies( @{$self->assembly_items} );
740 if ( $params{price_type} eq 'weight' ) {
741 return $part->items_weight_sum;
742 } elsif ( $params{price_type} eq 'lastcost' ) {
743 return $part->items_lastcost_sum;
745 return $part->items_sellprice_sum;
750 sub check_part_not_modified {
753 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
760 my $is_new = !$self->part->id;
762 my $params = delete($::form->{part}) || { };
764 delete $params->{id};
765 $self->part->assign_attributes(%{ $params});
766 $self->part->bin_id(undef) unless $self->part->warehouse_id;
768 $self->normalize_text_blocks;
770 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
771 # will be the case for used assortments when saving, or when a used assortment
773 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
774 $self->part->assortment_items([]);
775 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
778 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
779 $self->part->assemblies([]); # completely rewrite assortments each time
780 $self->part->add_assemblies( @{ $self->assembly_items } );
783 $self->part->translations([]);
784 $self->parse_form_translations;
786 $self->part->prices([]);
787 $self->parse_form_prices;
789 $self->parse_form_customerprices;
790 $self->parse_form_makemodels;
793 sub parse_form_prices {
795 # only save prices > 0
796 my $prices = delete($::form->{prices}) || [];
797 foreach my $price ( @{$prices} ) {
798 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
799 next unless $sellprice > 0; # skip negative prices as well
800 my $p = SL::DB::Price->new(parts_id => $self->part->id,
801 pricegroup_id => $price->{pricegroup_id},
804 $self->part->add_prices($p);
808 sub parse_form_translations {
810 # don't add empty translations
811 my $translations = delete($::form->{translations}) || [];
812 foreach my $translation ( @{$translations} ) {
813 next unless $translation->{translation};
814 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
815 $self->part->add_translations( $translation );
819 sub parse_form_makemodels {
823 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
824 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
827 $self->part->makemodels([]);
830 my $makemodels = delete($::form->{makemodels}) || [];
831 foreach my $makemodel ( @{$makemodels} ) {
832 next unless $makemodel->{make};
834 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
836 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
837 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
839 make => $makemodel->{make},
840 model => $makemodel->{model} || '',
841 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
842 sortorder => $position,
844 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
845 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
846 # don't change lastupdate
847 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
848 # new makemodel, no lastcost entered, leave lastupdate empty
849 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
850 # lastcost hasn't changed, use original lastupdate
851 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
853 $mm->lastupdate(DateTime->now);
855 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
856 $self->part->add_makemodels($mm);
860 sub parse_form_customerprices {
863 my $customerprices_map;
864 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
865 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
868 $self->part->customerprices([]);
871 my $customerprices = delete($::form->{customerprices}) || [];
872 foreach my $customerprice ( @{$customerprices} ) {
873 next unless $customerprice->{customer_id};
875 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
877 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
878 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
880 customer_id => $customerprice->{customer_id},
881 customer_partnumber => $customerprice->{customer_partnumber} || '',
882 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
883 sortorder => $position,
885 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
886 # lastupdate isn't set, original price is 0 and new lastcost is 0
887 # don't change lastupdate
888 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
889 # new customerprice, no lastcost entered, leave lastupdate empty
890 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
891 # price hasn't changed, use original lastupdate
892 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
894 $cu->lastupdate(DateTime->now);
896 $self->part->add_customerprices($cu);
900 sub build_bin_select {
901 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
902 title_key => 'description',
903 default => $_[0]->bin->id,
908 # get_set_inits for partpicker
911 if ($::form->{no_paginate}) {
912 $_[0]->models->disable_plugin('paginated');
918 # get_set_inits for part controller
922 # used by edit, save, delete and add
924 if ( $::form->{part}{id} ) {
925 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
926 } elsif ( $::form->{id} ) {
927 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
929 die "part_type missing" unless $::form->{part}{part_type};
930 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
936 return $self->part->orphaned;
942 SL::Controller::Helper::GetModels->new(
949 partnumber => t8('Partnumber'),
950 description => t8('Description'),
952 with_objects => [ qw(unit_obj classification) ],
961 sub init_assortment_items {
962 # this init is used while saving and whenever assortments change dynamically
966 my $assortment_items = delete($::form->{assortment_items}) || [];
967 foreach my $assortment_item ( @{$assortment_items} ) {
968 next unless $assortment_item->{parts_id};
970 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
971 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
972 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
973 charge => $assortment_item->{charge},
974 unit => $assortment_item->{unit} || $part->unit,
975 position => $position,
983 sub init_makemodels {
987 my @makemodel_array = ();
988 my $makemodels = delete($::form->{makemodels}) || [];
990 foreach my $makemodel ( @{$makemodels} ) {
991 next unless $makemodel->{make};
993 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
994 id => $makemodel->{id},
995 make => $makemodel->{make},
996 model => $makemodel->{model} || '',
997 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
998 sortorder => $position,
999 ) or die "Can't create mm";
1000 # $mm->id($makemodel->{id}) if $makemodel->{id};
1001 push(@makemodel_array, $mm);
1003 return \@makemodel_array;
1006 sub init_customerprices {
1010 my @customerprice_array = ();
1011 my $customerprices = delete($::form->{customerprices}) || [];
1013 foreach my $customerprice ( @{$customerprices} ) {
1014 next unless $customerprice->{customer_id};
1016 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1017 id => $customerprice->{id},
1018 customer_partnumber => $customerprice->{customer_partnumber},
1019 customer_id => $customerprice->{customer_id} || '',
1020 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1021 sortorder => $position,
1022 ) or die "Can't create cu";
1023 # $cu->id($customerprice->{id}) if $customerprice->{id};
1024 push(@customerprice_array, $cu);
1026 return \@customerprice_array;
1029 sub init_assembly_items {
1033 my $assembly_items = delete($::form->{assembly_items}) || [];
1034 foreach my $assembly_item ( @{$assembly_items} ) {
1035 next unless $assembly_item->{parts_id};
1037 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1038 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1039 bom => $assembly_item->{bom},
1040 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1041 position => $position,
1048 sub init_all_warehouses {
1050 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1053 sub init_all_languages {
1054 SL::DB::Manager::Language->get_all_sorted;
1057 sub init_all_partsgroups {
1059 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1062 sub init_all_buchungsgruppen {
1064 if ( $self->part->orphaned ) {
1065 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1067 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1071 sub init_shops_not_assigned {
1074 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1075 if ( @used_shop_ids ) {
1076 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1079 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1083 sub init_all_units {
1085 if ( $self->part->orphaned ) {
1086 return SL::DB::Manager::Unit->get_all_sorted;
1088 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1092 sub init_all_payment_terms {
1094 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1097 sub init_all_price_factors {
1098 SL::DB::Manager::PriceFactor->get_all_sorted;
1101 sub init_all_pricegroups {
1102 SL::DB::Manager::Pricegroup->get_all_sorted;
1105 # model used to filter/display the parts in the multi-items dialog
1106 sub init_multi_items_models {
1107 SL::Controller::Helper::GetModels->new(
1108 controller => $_[0],
1110 with_objects => [ qw(unit_obj partsgroup classification) ],
1111 disable_plugin => 'paginated',
1112 source => $::form->{multi_items},
1118 partnumber => t8('Partnumber'),
1119 description => t8('Description')}
1123 sub init_parts_classification_filter {
1124 return [] unless $::form->{parts_classification_type};
1126 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1127 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1129 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1132 # simple checks to run on $::form before saving
1134 sub form_check_part_description_exists {
1137 return 1 if $::form->{part}{description};
1139 $self->js->flash('error', t8('Part Description missing!'))
1140 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1141 ->focus('#part_description');
1145 sub form_check_assortment_items_exist {
1148 return 1 unless $::form->{part}{part_type} eq 'assortment';
1149 # skip item check for existing assortments that have been used
1150 return 1 if ($self->part->id and !$self->part->orphaned);
1152 # new or orphaned parts must have items in $::form->{assortment_items}
1153 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1154 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1155 ->focus('#add_assortment_item_name')
1156 ->flash('error', t8('The assortment doesn\'t have any items.'));
1162 sub form_check_assortment_items_unique {
1165 return 1 unless $::form->{part}{part_type} eq 'assortment';
1167 my %duplicate_elements;
1169 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1170 $duplicate_elements{$_}++ if $count{$_}++;
1173 if ( keys %duplicate_elements ) {
1174 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1175 ->flash('error', t8('There are duplicate assortment items'));
1181 sub form_check_assembly_items_exist {
1184 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1186 # skip item check for existing assembly that have been used
1187 return 1 if ($self->part->id and !$self->part->orphaned);
1189 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1190 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1191 ->focus('#add_assembly_item_name')
1192 ->flash('error', t8('The assembly doesn\'t have any items.'));
1198 sub form_check_partnumber_is_unique {
1201 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1202 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1204 $self->js->flash('error', t8('The partnumber already exists!'))
1205 ->focus('#part_description');
1212 # general checking functions
1215 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1221 $self->form_check_part_description_exists || return 0;
1222 $self->form_check_assortment_items_exist || return 0;
1223 $self->form_check_assortment_items_unique || return 0;
1224 $self->form_check_assembly_items_exist || return 0;
1225 $self->form_check_partnumber_is_unique || return 0;
1230 sub check_has_valid_part_type {
1231 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1235 sub normalize_text_blocks {
1238 # check if feature is enabled (select normalize_part_descriptions from defaults)
1239 return unless ($::instance_conf->get_normalize_part_descriptions);
1242 foreach (qw(description)) {
1243 $self->part->{$_} =~ s/\s+$//s;
1244 $self->part->{$_} =~ s/^\s+//s;
1245 $self->part->{$_} =~ s/ {2,}/ /g;
1247 # html block (caveat: can be circumvented by using bold or italics)
1248 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1249 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1253 sub render_assortment_items_to_html {
1254 my ($self, $assortment_items, $number_of_items) = @_;
1256 my $position = $number_of_items + 1;
1258 foreach my $ai (@$assortment_items) {
1259 $html .= $self->p->render('part/_assortment_row',
1260 PART => $self->part,
1261 orphaned => $self->orphaned,
1263 listrow => $position % 2 ? 1 : 0,
1264 position => $position, # for legacy assemblies
1271 sub render_assembly_items_to_html {
1272 my ($self, $assembly_items, $number_of_items) = @_;
1274 my $position = $number_of_items + 1;
1276 foreach my $ai (@{$assembly_items}) {
1277 $html .= $self->p->render('part/_assembly_row',
1278 PART => $self->part,
1279 orphaned => $self->orphaned,
1281 listrow => $position % 2 ? 1 : 0,
1282 position => $position, # for legacy assemblies
1289 sub parse_add_items_to_objects {
1290 my ($self, %params) = @_;
1291 my $part_type = $params{part_type};
1292 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1293 my $position = $params{position} || 1;
1295 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1298 foreach my $item ( @add_items ) {
1299 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1301 if ( $part_type eq 'assortment' ) {
1302 $ai = SL::DB::AssortmentItem->new(part => $part,
1303 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1304 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1305 position => $position,
1306 ) or die "Can't create AssortmentItem from item";
1307 } elsif ( $part_type eq 'assembly' ) {
1308 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1309 # id => $self->assembly->id, # will be set on save
1310 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1311 bom => 0, # default when adding: no bom
1312 position => $position,
1315 die "part_type must be assortment or assembly";
1317 push(@item_objects, $ai);
1321 return \@item_objects;
1324 sub _setup_form_action_bar {
1327 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1328 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1330 for my $bar ($::request->layout->get('actionbar')) {
1335 call => [ 'kivi.Part.save' ],
1336 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1340 call => [ 'kivi.Part.use_as_new' ],
1341 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1342 : !$may_edit ? t8('You do not have the permissions to access this function.')
1345 ], # end of combobox "Save"
1349 call => [ 'kivi.Part.delete' ],
1350 confirm => t8('Do you really want to delete this object?'),
1351 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1352 : !$may_edit ? t8('You do not have the permissions to access this function.')
1353 : !$self->part->orphaned ? t8('This object has already been used.')
1354 : $used_in_pricerules ? t8('This object is used in price rules.')
1362 call => [ 'kivi.Part.open_history_popup' ],
1363 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1364 : !$may_edit ? t8('You do not have the permissions to access this function.')
1379 SL::Controller::Part - Part CRUD controller
1383 Controller for adding/editing/saving/deleting parts.
1385 All the relations are loaded at once and saving the part, adding a history
1386 entry and saving CVars happens inside one transaction. When saving the old
1387 relations are deleted and written as new to the database.
1389 Relations for parts:
1397 =item assembly items
1399 =item assortment items
1407 There are 4 different part types:
1413 The "default" part type.
1415 inventory_accno_id is set.
1419 Services can't be stocked.
1421 inventory_accno_id isn't set.
1425 Assemblies consist of other parts, services, assemblies or assortments. They
1426 aren't meant to be bought, only sold. To add assemblies to stock you typically
1427 have to make them, which reduces the stock by its respective components. Once
1428 an assembly item has been created there is currently no way to "disassemble" it
1429 again. An assembly item can appear several times in one assembly. An assmbly is
1430 sold as one item with a defined sellprice and lastcost. If the component prices
1431 change the assortment price remains the same. The assembly items may be printed
1432 in a record if the item's "bom" is set.
1436 Similar to assembly, but each assortment item may only appear once per
1437 assortment. When selling an assortment the assortment items are added to the
1438 record together with the assortment, which is added with sellprice 0.
1440 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1441 determined by the sum of the current assortment item prices when the assortment
1442 is added to a record. This also means that price rules and customer discounts
1443 will be applied to the assortment items.
1445 Once the assortment items have been added they may be modified or deleted, just
1446 as if they had been added manually, the individual assortment items aren't
1447 linked to the assortment or the other assortment items in any way.
1455 =item C<action_add_part>
1457 =item C<action_add_service>
1459 =item C<action_add_assembly>
1461 =item C<action_add_assortment>
1463 =item C<action_add PART_TYPE>
1465 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1466 parameter part_type as an action. Example:
1468 controller.pl?action=Part/add&part_type=service
1470 =item C<action_add_from_record>
1472 When adding new items to records they can be created on the fly if the entered
1473 partnumber or description doesn't exist yet. After being asked what part type
1474 the new item should have the user is redirected to the correct edit page.
1476 Depending on whether the item was added from a sales or a purchase record, only
1477 the relevant part classifications should be selectable for new item, so this
1478 parameter is passed on via a hidden parts_classification_type in the new_item
1481 =item C<action_save>
1483 Saves the current part and then reloads the edit page for the part.
1485 =item C<action_use_as_new>
1487 Takes the information from the current part, plus any modifications made on the
1488 page, and creates a new edit page that is ready to be saved. The partnumber is
1489 set empty, so a new partnumber from the number range will be used if the user
1490 doesn't enter one manually.
1492 Unsaved changes to the original part aren't updated.
1494 The part type cannot be changed in this way.
1496 =item C<action_delete>
1498 Deletes the current part and then redirects to the main page, there is no
1501 The delete button only appears if the part is 'orphaned', according to
1502 SL::DB::Part orphaned.
1504 The part can't be deleted if it appears in invoices, orders, delivery orders,
1505 the inventory, or is part of an assembly or assortment.
1507 If the part is deleted its relations prices, makdemodel, assembly,
1508 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1510 Before this controller items that appeared in inventory didn't count as
1511 orphaned and could be deleted and the inventory entries were also deleted, this
1512 "feature" hasn't been implemented.
1514 =item C<action_edit part.id>
1516 Load and display a part for editing.
1518 controller.pl?action=Part/edit&part.id=12345
1520 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1524 =head1 BUTTON ACTIONS
1530 Opens a popup displaying all the history entries. Once a new history controller
1531 is written the button could link there instead, with the part already selected.
1539 =item C<action_update_item_totals>
1541 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1542 amount of an item changes. The sum of all sellprices and lastcosts is
1543 calculated and the totals updated. Uses C<recalc_item_totals>.
1545 =item C<action_add_assortment_item>
1547 Adds a new assortment item from a part picker seleciton to the assortment item list
1549 If the item already exists in the assortment the item isn't added and a Flash
1552 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1553 after adding each new item, add the new object to the item objects that were
1554 already parsed, calculate totals via a dummy part then update the row and the
1557 =item C<action_add_assembly_item>
1559 Adds a new assembly item from a part picker seleciton to the assembly item list
1561 If the item already exists in the assembly a flash info is generated, but the
1564 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1565 after adding each new item, add the new object to the item objects that were
1566 already parsed, calculate totals via a dummy part then update the row and the
1569 =item C<action_add_multi_assortment_items>
1571 Parses the items to be added from the form generated by the multi input and
1572 appends the html of the tr-rows to the assortment item table. Afterwards all
1573 assortment items are renumbered and the sums recalculated via
1574 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1576 =item C<action_add_multi_assembly_items>
1578 Parses the items to be added from the form generated by the multi input and
1579 appends the html of the tr-rows to the assembly item table. Afterwards all
1580 assembly items are renumbered and the sums recalculated via
1581 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1583 =item C<action_show_multi_items_dialog>
1585 =item C<action_multi_items_update_result>
1587 =item C<action_add_makemodel_row>
1589 Add a new makemodel row with the vendor that was selected via the vendor
1592 Checks the already existing makemodels and warns if a row with that vendor
1593 already exists. Currently it is possible to have duplicate vendor rows.
1595 =item C<action_reorder_items>
1597 Sorts the item table for assembly or assortment items.
1599 =item C<action_warehouse_changed>
1603 =head1 ACTIONS part picker
1607 =item C<action_ajax_autocomplete>
1609 =item C<action_test_page>
1611 =item C<action_part_picker_search>
1613 =item C<action_part_picker_result>
1615 =item C<action_show>
1625 Calls some simple checks that test the submitted $::form for obvious errors.
1626 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1628 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1629 some cases extra actions are taken, e.g. if the part description is missing the
1630 basic data tab is selected and the description input field is focussed.
1636 =item C<form_check_part_description_exists>
1638 =item C<form_check_assortment_items_exist>
1640 =item C<form_check_assortment_items_unique>
1642 =item C<form_check_assembly_items_exist>
1644 =item C<form_check_partnumber_is_unique>
1648 =head1 HELPER FUNCTIONS
1654 When submitting the form for saving, parses the transmitted form. Expects the
1658 $::form->{makemodels}
1659 $::form->{translations}
1661 $::form->{assemblies}
1662 $::form->{assortments}
1664 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1666 =item C<recalc_item_totals %params>
1668 Helper function for calculating the total lastcost and sellprice for assemblies
1669 or assortments according to their items, which are parsed from the current
1672 Is called whenever the qty of an item is changed or items are deleted.
1676 * part_type : 'assortment' or 'assembly' (mandatory)
1678 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1680 Depending on the price_type the lastcost sum or sellprice sum is returned.
1682 Doesn't work for recursive items.
1686 =head1 GET SET INITS
1688 There are get_set_inits for
1696 which parse $::form and automatically create an array of objects.
1698 These inits are used during saving and each time a new element is added.
1702 =item C<init_makemodels>
1704 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1705 $self->part->makemodels, ready to be saved.
1707 Used for saving parts and adding new makemodel rows.
1709 =item C<parse_add_items_to_objects PART_TYPE>
1711 Parses the resulting form from either the part-picker submit or the multi-item
1712 submit, and creates an arrayref of assortment_item or assembly objects, that
1713 can be rendered via C<render_assortment_items_to_html> or
1714 C<render_assembly_items_to_html>.
1716 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1717 Optional param: position (used for numbering and listrow class)
1719 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1721 Takes an array_ref of assortment_items, and generates tables rows ready for
1722 adding to the assortment table. Is used when a part is loaded, or whenever new
1723 assortment items are added.
1725 =item C<parse_form_makemodels>
1727 Makemodels can't just be overwritten, because of the field "lastupdate", that
1728 remembers when the lastcost for that vendor changed the last time.
1730 So the original values are cloned and remembered, so we can compare if lastcost
1731 was changed in $::form, and keep or update lastupdate.
1733 lastcost isn't updated until the first time it was saved with a value, until
1736 Also a boolean "makemodel" needs to be written in parts, depending on whether
1737 makemodel entries exist or not.
1739 We still need init_makemodels for when we open the part for editing.
1749 It should be possible to jump to the edit page in a specific tab
1753 Support callbacks, e.g. creating a new part from within an order, and jumping
1754 back to the order again afterwards.
1758 Support units when adding assembly items or assortment items. Currently the
1759 default unit of the item is always used.
1763 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1764 consists of other assemblies.
1770 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>