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);
166 if ( $::form->{callback} ) {
167 $self->redirect_to($::form->unescape($::form->{callback}));
174 my $db = $self->part->db; # $self->part has a get_set_init on $::form
176 my $partnumber = $self->part->partnumber; # remember for history log
181 # delete part, together with relationships that don't already
182 # have an ON DELETE CASCADE, e.g. makemodel and translation.
183 $self->part->delete(cascade => 1);
185 SL::DB::History->new(
186 trans_id => $self->part->id,
187 snumbers => 'partnumber_' . $partnumber,
188 employee_id => SL::DB::Manager::Employee->current->id,
190 addition => 'DELETED',
193 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
195 flash_later('info', t8('The item has been deleted.'));
196 if ( $::form->{callback} ) {
197 $self->redirect_to($::form->unescape($::form->{callback}));
199 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
203 sub action_use_as_new {
204 my ($self, %params) = @_;
206 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
207 $::form->{oldpartnumber} = $oldpart->partnumber;
209 $self->part($oldpart->clone_and_reset_deep);
211 $self->part->partnumber(undef);
217 my ($self, %params) = @_;
223 my ($self, %params) = @_;
225 $self->_set_javascript;
226 $self->_setup_form_action_bar;
228 my (%assortment_vars, %assembly_vars);
229 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
230 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
232 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
234 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
235 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
236 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
237 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
240 my %title_hash = ( part => t8('Edit Part'),
241 assembly => t8('Edit Assembly'),
242 service => t8('Edit Service'),
243 assortment => t8('Edit Assortment'),
246 $self->part->prices([]) unless $self->part->prices;
247 $self->part->translations([]) unless $self->part->translations;
251 title => $title_hash{$self->part->part_type},
254 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
255 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
256 oldpartnumber => $::form->{oldpartnumber},
257 old_id => $::form->{old_id},
265 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
266 $_[0]->render('part/history', { layout => 0 },
267 history_entries => $history_entries);
270 sub action_inventory {
273 $::auth->assert('warehouse_contents');
275 $self->stock_amounts($self->part->get_simple_stock_sql);
276 $self->journal($self->part->get_mini_journal);
278 $_[0]->render('part/_inventory_data', { layout => 0 });
281 sub action_update_item_totals {
284 my $part_type = $::form->{part_type};
285 die unless $part_type =~ /^(assortment|assembly)$/;
287 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
288 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
289 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
291 my $sum_diff = $sellprice_sum-$lastcost_sum;
294 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
295 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
296 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
297 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
298 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
299 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
300 ->no_flash_clear->render();
303 sub action_add_multi_assortment_items {
306 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
307 my $html = $self->render_assortment_items_to_html($item_objects);
309 $self->js->run('kivi.Part.close_picker_dialogs')
310 ->append('#assortment_rows', $html)
311 ->run('kivi.Part.renumber_positions')
312 ->run('kivi.Part.assortment_recalc')
316 sub action_add_multi_assembly_items {
319 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
321 foreach my $item (@{$item_objects}) {
322 my $errstr = validate_assembly($item->part,$self->part);
323 $self->js->flash('error',$errstr) if $errstr;
324 push (@checked_objects,$item) unless $errstr;
327 my $html = $self->render_assembly_items_to_html(\@checked_objects);
329 $self->js->run('kivi.Part.close_picker_dialogs')
330 ->append('#assembly_rows', $html)
331 ->run('kivi.Part.renumber_positions')
332 ->run('kivi.Part.assembly_recalc')
336 sub action_add_assortment_item {
337 my ($self, %params) = @_;
339 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
341 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
343 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
344 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
345 return $self->js->flash('error', t8("This part has already been added."))->render;
348 my $number_of_items = scalar @{$self->assortment_items};
349 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
350 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
352 push(@{$self->assortment_items}, @{$item_objects});
353 my $part = SL::DB::Part->new(part_type => 'assortment');
354 $part->assortment_items(@{$self->assortment_items});
355 my $items_sellprice_sum = $part->items_sellprice_sum;
356 my $items_lastcost_sum = $part->items_lastcost_sum;
357 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
360 ->append('#assortment_rows' , $html) # append in tbody
361 ->val('.add_assortment_item_input' , '')
362 ->run('kivi.Part.focus_last_assortment_input')
363 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
364 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
365 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
366 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
367 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
371 sub action_add_assembly_item {
374 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
376 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
378 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
380 my $duplicate_warning = 0; # duplicates are allowed, just warn
381 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
382 $duplicate_warning++;
385 my $number_of_items = scalar @{$self->assembly_items};
386 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
388 foreach my $item (@{$item_objects}) {
389 my $errstr = validate_assembly($item->part,$self->part);
390 return $self->js->flash('error',$errstr)->render if $errstr;
395 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
397 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
399 push(@{$self->assembly_items}, @{$item_objects});
400 my $part = SL::DB::Part->new(part_type => 'assembly');
401 $part->assemblies(@{$self->assembly_items});
402 my $items_sellprice_sum = $part->items_sellprice_sum;
403 my $items_lastcost_sum = $part->items_lastcost_sum;
404 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
405 my $items_weight_sum = $part->items_weight_sum;
408 ->append('#assembly_rows', $html) # append in tbody
409 ->val('.add_assembly_item_input' , '')
410 ->run('kivi.Part.focus_last_assembly_input')
411 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
412 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
413 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
414 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
415 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
416 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
420 sub action_show_multi_items_dialog {
423 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
424 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
425 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
427 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
428 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
429 search_term => $search_term
433 sub action_multi_items_update_result {
434 my $max_count = $::form->{limit};
436 my $count = $_[0]->multi_items_models->count;
439 my $text = escape($::locale->text('No results.'));
440 $_[0]->render($text, { layout => 0 });
441 } elsif ($max_count && $count > $max_count) {
442 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
443 $_[0]->render($text, { layout => 0 });
445 my $multi_items = $_[0]->multi_items_models->get;
446 $_[0]->render('part/_multi_items_result', { layout => 0 },
447 multi_items => $multi_items);
451 sub action_add_makemodel_row {
454 my $vendor_id = $::form->{add_makemodel};
456 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
457 return $self->js->error(t8("No vendor selected or found!"))->render;
459 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
460 $self->js->flash('info', t8("This vendor has already been added."));
463 my $position = scalar @{$self->makemodels} + 1;
465 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
469 sortorder => $position,
470 ) or die "Can't create MakeModel object";
472 my $row_as_html = $self->p->render('part/_makemodel_row',
474 listrow => $position % 2 ? 0 : 1,
477 # after selection focus on the model field in the row that was just added
479 ->append('#makemodel_rows', $row_as_html) # append in tbody
480 ->val('.add_makemodel_input', '')
481 ->run('kivi.Part.focus_last_makemodel_input')
485 sub action_add_customerprice_row {
488 my $customer_id = $::form->{add_customerprice};
490 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
491 or return $self->js->error(t8("No customer selected or found!"))->render;
493 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
494 $self->js->flash('info', t8("This customer has already been added."));
497 my $position = scalar @{ $self->customerprices } + 1;
499 my $cu = SL::DB::PartCustomerPrice->new(
500 customer_id => $customer->id,
501 customer_partnumber => '',
503 sortorder => $position,
504 ) or die "Can't create Customerprice object";
506 my $row_as_html = $self->p->render(
507 'part/_customerprice_row',
508 customerprice => $cu,
509 listrow => $position % 2 ? 0
513 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
514 ->val('.add_customerprice_input', '')
515 ->run('kivi.Part.focus_last_customerprice_input')->render;
518 sub action_reorder_items {
521 my $part_type = $::form->{part_type};
524 partnumber => sub { $_[0]->part->partnumber },
525 description => sub { $_[0]->part->description },
526 qty => sub { $_[0]->qty },
527 sellprice => sub { $_[0]->part->sellprice },
528 lastcost => sub { $_[0]->part->lastcost },
529 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
532 my $method = $sort_keys{$::form->{order_by}};
535 if ($part_type eq 'assortment') {
536 @items = @{ $self->assortment_items };
538 @items = @{ $self->assembly_items };
541 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
542 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
543 if ($::form->{sort_dir}) {
544 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
546 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
549 if ($::form->{sort_dir}) {
550 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
552 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
556 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
559 sub action_warehouse_changed {
562 if ($::form->{warehouse_id} ) {
563 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
564 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
566 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
567 $self->bin($self->warehouse->bins_sorted->[0]);
569 ->html('#bin', $self->build_bin_select)
570 ->focus('#part_bin_id');
571 return $self->js->render;
575 # no warehouse was selected, empty the bin field and reset the id
577 ->val('#part_bin_id', undef)
580 return $self->js->render;
583 sub action_ajax_autocomplete {
584 my ($self, %params) = @_;
586 # if someone types something, and hits enter, assume he entered the full name.
587 # if something matches, treat that as sole match
588 # since we need a second get models instance with different filters for that,
589 # we only modify the original filter temporarily in place
590 if ($::form->{prefer_exact}) {
591 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
592 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
593 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
595 my $exact_models = SL::Controller::Helper::GetModels->new(
598 paginated => { per_page => 2 },
599 with_objects => [ qw(unit_obj classification) ],
602 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
603 $self->parts($exact_matches);
609 value => $_->displayable_name,
610 label => $_->displayable_name,
612 partnumber => $_->partnumber,
613 description => $_->description,
615 part_type => $_->part_type,
617 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
619 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
621 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
624 sub action_test_page {
625 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
628 sub action_part_picker_search {
631 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
632 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
633 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
635 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
638 sub action_part_picker_result {
639 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
645 if ($::request->type eq 'json') {
650 $part_hash = $self->part->as_tree;
651 $part_hash->{cvars} = $self->part->cvar_as_hashref;
654 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
659 sub validate_add_items {
660 scalar @{$::form->{add_items}};
663 sub prepare_assortment_render_vars {
666 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
667 items_lastcost_sum => $self->part->items_lastcost_sum,
668 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
670 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
675 sub prepare_assembly_render_vars {
678 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
680 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
681 items_lastcost_sum => $self->part->items_lastcost_sum,
682 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
684 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
692 check_has_valid_part_type($self->part->part_type);
694 $self->_set_javascript;
695 $self->_setup_form_action_bar;
697 my %title_hash = ( part => t8('Add Part'),
698 assembly => t8('Add Assembly'),
699 service => t8('Add Service'),
700 assortment => t8('Add Assortment'),
705 title => $title_hash{$self->part->part_type},
710 sub _set_javascript {
712 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
713 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
716 sub recalc_item_totals {
717 my ($self, %params) = @_;
719 if ( $params{part_type} eq 'assortment' ) {
720 return 0 unless scalar @{$self->assortment_items};
721 } elsif ( $params{part_type} eq 'assembly' ) {
722 return 0 unless scalar @{$self->assembly_items};
724 carp "can only calculate sum for assortments and assemblies";
727 my $part = SL::DB::Part->new(part_type => $params{part_type});
728 if ( $part->is_assortment ) {
729 $part->assortment_items( @{$self->assortment_items} );
730 if ( $params{price_type} eq 'lastcost' ) {
731 return $part->items_lastcost_sum;
733 if ( $params{pricegroup_id} ) {
734 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
736 return $part->items_sellprice_sum;
739 } elsif ( $part->is_assembly ) {
740 $part->assemblies( @{$self->assembly_items} );
741 if ( $params{price_type} eq 'weight' ) {
742 return $part->items_weight_sum;
743 } elsif ( $params{price_type} eq 'lastcost' ) {
744 return $part->items_lastcost_sum;
746 return $part->items_sellprice_sum;
751 sub check_part_not_modified {
754 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
761 my $is_new = !$self->part->id;
763 my $params = delete($::form->{part}) || { };
765 delete $params->{id};
766 $self->part->assign_attributes(%{ $params});
767 $self->part->bin_id(undef) unless $self->part->warehouse_id;
769 $self->normalize_text_blocks;
771 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
772 # will be the case for used assortments when saving, or when a used assortment
774 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
775 $self->part->assortment_items([]);
776 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
779 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
780 $self->part->assemblies([]); # completely rewrite assortments each time
781 $self->part->add_assemblies( @{ $self->assembly_items } );
784 $self->part->translations([]);
785 $self->parse_form_translations;
787 $self->part->prices([]);
788 $self->parse_form_prices;
790 $self->parse_form_customerprices;
791 $self->parse_form_makemodels;
794 sub parse_form_prices {
796 # only save prices > 0
797 my $prices = delete($::form->{prices}) || [];
798 foreach my $price ( @{$prices} ) {
799 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
800 next unless $sellprice > 0; # skip negative prices as well
801 my $p = SL::DB::Price->new(parts_id => $self->part->id,
802 pricegroup_id => $price->{pricegroup_id},
805 $self->part->add_prices($p);
809 sub parse_form_translations {
811 # don't add empty translations
812 my $translations = delete($::form->{translations}) || [];
813 foreach my $translation ( @{$translations} ) {
814 next unless $translation->{translation};
815 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
816 $self->part->add_translations( $translation );
820 sub parse_form_makemodels {
824 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
825 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
828 $self->part->makemodels([]);
831 my $makemodels = delete($::form->{makemodels}) || [];
832 foreach my $makemodel ( @{$makemodels} ) {
833 next unless $makemodel->{make};
835 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
837 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
838 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
840 make => $makemodel->{make},
841 model => $makemodel->{model} || '',
842 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
843 sortorder => $position,
845 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
846 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
847 # don't change lastupdate
848 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
849 # new makemodel, no lastcost entered, leave lastupdate empty
850 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
851 # lastcost hasn't changed, use original lastupdate
852 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
854 $mm->lastupdate(DateTime->now);
856 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
857 $self->part->add_makemodels($mm);
861 sub parse_form_customerprices {
864 my $customerprices_map;
865 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
866 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
869 $self->part->customerprices([]);
872 my $customerprices = delete($::form->{customerprices}) || [];
873 foreach my $customerprice ( @{$customerprices} ) {
874 next unless $customerprice->{customer_id};
876 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
878 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
879 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
881 customer_id => $customerprice->{customer_id},
882 customer_partnumber => $customerprice->{customer_partnumber} || '',
883 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
884 sortorder => $position,
886 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
887 # lastupdate isn't set, original price is 0 and new lastcost is 0
888 # don't change lastupdate
889 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
890 # new customerprice, no lastcost entered, leave lastupdate empty
891 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
892 # price hasn't changed, use original lastupdate
893 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
895 $cu->lastupdate(DateTime->now);
897 $self->part->add_customerprices($cu);
901 sub build_bin_select {
902 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
903 title_key => 'description',
904 default => $_[0]->bin->id,
909 # get_set_inits for partpicker
912 if ($::form->{no_paginate}) {
913 $_[0]->models->disable_plugin('paginated');
919 # get_set_inits for part controller
923 # used by edit, save, delete and add
925 if ( $::form->{part}{id} ) {
926 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
927 } elsif ( $::form->{id} ) {
928 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
930 die "part_type missing" unless $::form->{part}{part_type};
931 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
937 return $self->part->orphaned;
943 SL::Controller::Helper::GetModels->new(
950 partnumber => t8('Partnumber'),
951 description => t8('Description'),
953 with_objects => [ qw(unit_obj classification) ],
962 sub init_assortment_items {
963 # this init is used while saving and whenever assortments change dynamically
967 my $assortment_items = delete($::form->{assortment_items}) || [];
968 foreach my $assortment_item ( @{$assortment_items} ) {
969 next unless $assortment_item->{parts_id};
971 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
972 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
973 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
974 charge => $assortment_item->{charge},
975 unit => $assortment_item->{unit} || $part->unit,
976 position => $position,
984 sub init_makemodels {
988 my @makemodel_array = ();
989 my $makemodels = delete($::form->{makemodels}) || [];
991 foreach my $makemodel ( @{$makemodels} ) {
992 next unless $makemodel->{make};
994 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
995 id => $makemodel->{id},
996 make => $makemodel->{make},
997 model => $makemodel->{model} || '',
998 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
999 sortorder => $position,
1000 ) or die "Can't create mm";
1001 # $mm->id($makemodel->{id}) if $makemodel->{id};
1002 push(@makemodel_array, $mm);
1004 return \@makemodel_array;
1007 sub init_customerprices {
1011 my @customerprice_array = ();
1012 my $customerprices = delete($::form->{customerprices}) || [];
1014 foreach my $customerprice ( @{$customerprices} ) {
1015 next unless $customerprice->{customer_id};
1017 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1018 id => $customerprice->{id},
1019 customer_partnumber => $customerprice->{customer_partnumber},
1020 customer_id => $customerprice->{customer_id} || '',
1021 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1022 sortorder => $position,
1023 ) or die "Can't create cu";
1024 # $cu->id($customerprice->{id}) if $customerprice->{id};
1025 push(@customerprice_array, $cu);
1027 return \@customerprice_array;
1030 sub init_assembly_items {
1034 my $assembly_items = delete($::form->{assembly_items}) || [];
1035 foreach my $assembly_item ( @{$assembly_items} ) {
1036 next unless $assembly_item->{parts_id};
1038 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1039 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1040 bom => $assembly_item->{bom},
1041 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1042 position => $position,
1049 sub init_all_warehouses {
1051 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1054 sub init_all_languages {
1055 SL::DB::Manager::Language->get_all_sorted;
1058 sub init_all_partsgroups {
1060 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1063 sub init_all_buchungsgruppen {
1065 if ( $self->part->orphaned ) {
1066 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1068 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1072 sub init_shops_not_assigned {
1075 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1076 if ( @used_shop_ids ) {
1077 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1080 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1084 sub init_all_units {
1086 if ( $self->part->orphaned ) {
1087 return SL::DB::Manager::Unit->get_all_sorted;
1089 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1093 sub init_all_payment_terms {
1095 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1098 sub init_all_price_factors {
1099 SL::DB::Manager::PriceFactor->get_all_sorted;
1102 sub init_all_pricegroups {
1103 SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]);
1106 # model used to filter/display the parts in the multi-items dialog
1107 sub init_multi_items_models {
1108 SL::Controller::Helper::GetModels->new(
1109 controller => $_[0],
1111 with_objects => [ qw(unit_obj partsgroup classification) ],
1112 disable_plugin => 'paginated',
1113 source => $::form->{multi_items},
1119 partnumber => t8('Partnumber'),
1120 description => t8('Description')}
1124 sub init_parts_classification_filter {
1125 return [] unless $::form->{parts_classification_type};
1127 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1128 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1130 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1133 # simple checks to run on $::form before saving
1135 sub form_check_part_description_exists {
1138 return 1 if $::form->{part}{description};
1140 $self->js->flash('error', t8('Part Description missing!'))
1141 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1142 ->focus('#part_description');
1146 sub form_check_assortment_items_exist {
1149 return 1 unless $::form->{part}{part_type} eq 'assortment';
1150 # skip item check for existing assortments that have been used
1151 return 1 if ($self->part->id and !$self->part->orphaned);
1153 # new or orphaned parts must have items in $::form->{assortment_items}
1154 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1155 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1156 ->focus('#add_assortment_item_name')
1157 ->flash('error', t8('The assortment doesn\'t have any items.'));
1163 sub form_check_assortment_items_unique {
1166 return 1 unless $::form->{part}{part_type} eq 'assortment';
1168 my %duplicate_elements;
1170 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1171 $duplicate_elements{$_}++ if $count{$_}++;
1174 if ( keys %duplicate_elements ) {
1175 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1176 ->flash('error', t8('There are duplicate assortment items'));
1182 sub form_check_assembly_items_exist {
1185 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1187 # skip item check for existing assembly that have been used
1188 return 1 if ($self->part->id and !$self->part->orphaned);
1190 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1191 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1192 ->focus('#add_assembly_item_name')
1193 ->flash('error', t8('The assembly doesn\'t have any items.'));
1199 sub form_check_partnumber_is_unique {
1202 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1203 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1205 $self->js->flash('error', t8('The partnumber already exists!'))
1206 ->focus('#part_description');
1213 # general checking functions
1216 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1222 $self->form_check_part_description_exists || return 0;
1223 $self->form_check_assortment_items_exist || return 0;
1224 $self->form_check_assortment_items_unique || return 0;
1225 $self->form_check_assembly_items_exist || return 0;
1226 $self->form_check_partnumber_is_unique || return 0;
1231 sub check_has_valid_part_type {
1232 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1236 sub normalize_text_blocks {
1239 # check if feature is enabled (select normalize_part_descriptions from defaults)
1240 return unless ($::instance_conf->get_normalize_part_descriptions);
1243 foreach (qw(description)) {
1244 $self->part->{$_} =~ s/\s+$//s;
1245 $self->part->{$_} =~ s/^\s+//s;
1246 $self->part->{$_} =~ s/ {2,}/ /g;
1248 # html block (caveat: can be circumvented by using bold or italics)
1249 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1250 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1254 sub render_assortment_items_to_html {
1255 my ($self, $assortment_items, $number_of_items) = @_;
1257 my $position = $number_of_items + 1;
1259 foreach my $ai (@$assortment_items) {
1260 $html .= $self->p->render('part/_assortment_row',
1261 PART => $self->part,
1262 orphaned => $self->orphaned,
1264 listrow => $position % 2 ? 1 : 0,
1265 position => $position, # for legacy assemblies
1272 sub render_assembly_items_to_html {
1273 my ($self, $assembly_items, $number_of_items) = @_;
1275 my $position = $number_of_items + 1;
1277 foreach my $ai (@{$assembly_items}) {
1278 $html .= $self->p->render('part/_assembly_row',
1279 PART => $self->part,
1280 orphaned => $self->orphaned,
1282 listrow => $position % 2 ? 1 : 0,
1283 position => $position, # for legacy assemblies
1290 sub parse_add_items_to_objects {
1291 my ($self, %params) = @_;
1292 my $part_type = $params{part_type};
1293 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1294 my $position = $params{position} || 1;
1296 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1299 foreach my $item ( @add_items ) {
1300 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1302 if ( $part_type eq 'assortment' ) {
1303 $ai = SL::DB::AssortmentItem->new(part => $part,
1304 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1305 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1306 position => $position,
1307 ) or die "Can't create AssortmentItem from item";
1308 } elsif ( $part_type eq 'assembly' ) {
1309 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1310 # id => $self->assembly->id, # will be set on save
1311 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1312 bom => 0, # default when adding: no bom
1313 position => $position,
1316 die "part_type must be assortment or assembly";
1318 push(@item_objects, $ai);
1322 return \@item_objects;
1325 sub _setup_form_action_bar {
1328 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1329 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1331 for my $bar ($::request->layout->get('actionbar')) {
1336 call => [ 'kivi.Part.save' ],
1337 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1341 call => [ 'kivi.Part.use_as_new' ],
1342 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1343 : !$may_edit ? t8('You do not have the permissions to access this function.')
1346 ], # end of combobox "Save"
1350 submit => [ '#ic', { action => "Part/abort" } ],
1351 only_if => !!$::form->{show_abort},
1356 call => [ 'kivi.Part.delete' ],
1357 confirm => t8('Do you really want to delete this object?'),
1358 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1359 : !$may_edit ? t8('You do not have the permissions to access this function.')
1360 : !$self->part->orphaned ? t8('This object has already been used.')
1361 : $used_in_pricerules ? t8('This object is used in price rules.')
1369 call => [ 'kivi.Part.open_history_popup' ],
1370 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1371 : !$may_edit ? t8('You do not have the permissions to access this function.')
1386 SL::Controller::Part - Part CRUD controller
1390 Controller for adding/editing/saving/deleting parts.
1392 All the relations are loaded at once and saving the part, adding a history
1393 entry and saving CVars happens inside one transaction. When saving the old
1394 relations are deleted and written as new to the database.
1396 Relations for parts:
1404 =item assembly items
1406 =item assortment items
1414 There are 4 different part types:
1420 The "default" part type.
1422 inventory_accno_id is set.
1426 Services can't be stocked.
1428 inventory_accno_id isn't set.
1432 Assemblies consist of other parts, services, assemblies or assortments. They
1433 aren't meant to be bought, only sold. To add assemblies to stock you typically
1434 have to make them, which reduces the stock by its respective components. Once
1435 an assembly item has been created there is currently no way to "disassemble" it
1436 again. An assembly item can appear several times in one assembly. An assmbly is
1437 sold as one item with a defined sellprice and lastcost. If the component prices
1438 change the assortment price remains the same. The assembly items may be printed
1439 in a record if the item's "bom" is set.
1443 Similar to assembly, but each assortment item may only appear once per
1444 assortment. When selling an assortment the assortment items are added to the
1445 record together with the assortment, which is added with sellprice 0.
1447 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1448 determined by the sum of the current assortment item prices when the assortment
1449 is added to a record. This also means that price rules and customer discounts
1450 will be applied to the assortment items.
1452 Once the assortment items have been added they may be modified or deleted, just
1453 as if they had been added manually, the individual assortment items aren't
1454 linked to the assortment or the other assortment items in any way.
1462 =item C<action_add_part>
1464 =item C<action_add_service>
1466 =item C<action_add_assembly>
1468 =item C<action_add_assortment>
1470 =item C<action_add PART_TYPE>
1472 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1473 parameter part_type as an action. Example:
1475 controller.pl?action=Part/add&part_type=service
1477 =item C<action_add_from_record>
1479 When adding new items to records they can be created on the fly if the entered
1480 partnumber or description doesn't exist yet. After being asked what part type
1481 the new item should have the user is redirected to the correct edit page.
1483 Depending on whether the item was added from a sales or a purchase record, only
1484 the relevant part classifications should be selectable for new item, so this
1485 parameter is passed on via a hidden parts_classification_type in the new_item
1488 =item C<action_save>
1490 Saves the current part and then reloads the edit page for the part.
1492 =item C<action_use_as_new>
1494 Takes the information from the current part, plus any modifications made on the
1495 page, and creates a new edit page that is ready to be saved. The partnumber is
1496 set empty, so a new partnumber from the number range will be used if the user
1497 doesn't enter one manually.
1499 Unsaved changes to the original part aren't updated.
1501 The part type cannot be changed in this way.
1503 =item C<action_delete>
1505 Deletes the current part and then redirects to the main page, there is no
1508 The delete button only appears if the part is 'orphaned', according to
1509 SL::DB::Part orphaned.
1511 The part can't be deleted if it appears in invoices, orders, delivery orders,
1512 the inventory, or is part of an assembly or assortment.
1514 If the part is deleted its relations prices, makdemodel, assembly,
1515 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1517 Before this controller items that appeared in inventory didn't count as
1518 orphaned and could be deleted and the inventory entries were also deleted, this
1519 "feature" hasn't been implemented.
1521 =item C<action_edit part.id>
1523 Load and display a part for editing.
1525 controller.pl?action=Part/edit&part.id=12345
1527 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1531 =head1 BUTTON ACTIONS
1537 Opens a popup displaying all the history entries. Once a new history controller
1538 is written the button could link there instead, with the part already selected.
1546 =item C<action_update_item_totals>
1548 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1549 amount of an item changes. The sum of all sellprices and lastcosts is
1550 calculated and the totals updated. Uses C<recalc_item_totals>.
1552 =item C<action_add_assortment_item>
1554 Adds a new assortment item from a part picker seleciton to the assortment item list
1556 If the item already exists in the assortment the item isn't added and a Flash
1559 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1560 after adding each new item, add the new object to the item objects that were
1561 already parsed, calculate totals via a dummy part then update the row and the
1564 =item C<action_add_assembly_item>
1566 Adds a new assembly item from a part picker seleciton to the assembly item list
1568 If the item already exists in the assembly a flash info is generated, but the
1571 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1572 after adding each new item, add the new object to the item objects that were
1573 already parsed, calculate totals via a dummy part then update the row and the
1576 =item C<action_add_multi_assortment_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 assortment item table. Afterwards all
1580 assortment items are renumbered and the sums recalculated via
1581 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1583 =item C<action_add_multi_assembly_items>
1585 Parses the items to be added from the form generated by the multi input and
1586 appends the html of the tr-rows to the assembly item table. Afterwards all
1587 assembly items are renumbered and the sums recalculated via
1588 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1590 =item C<action_show_multi_items_dialog>
1592 =item C<action_multi_items_update_result>
1594 =item C<action_add_makemodel_row>
1596 Add a new makemodel row with the vendor that was selected via the vendor
1599 Checks the already existing makemodels and warns if a row with that vendor
1600 already exists. Currently it is possible to have duplicate vendor rows.
1602 =item C<action_reorder_items>
1604 Sorts the item table for assembly or assortment items.
1606 =item C<action_warehouse_changed>
1610 =head1 ACTIONS part picker
1614 =item C<action_ajax_autocomplete>
1616 =item C<action_test_page>
1618 =item C<action_part_picker_search>
1620 =item C<action_part_picker_result>
1622 =item C<action_show>
1632 Calls some simple checks that test the submitted $::form for obvious errors.
1633 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1635 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1636 some cases extra actions are taken, e.g. if the part description is missing the
1637 basic data tab is selected and the description input field is focussed.
1643 =item C<form_check_part_description_exists>
1645 =item C<form_check_assortment_items_exist>
1647 =item C<form_check_assortment_items_unique>
1649 =item C<form_check_assembly_items_exist>
1651 =item C<form_check_partnumber_is_unique>
1655 =head1 HELPER FUNCTIONS
1661 When submitting the form for saving, parses the transmitted form. Expects the
1665 $::form->{makemodels}
1666 $::form->{translations}
1668 $::form->{assemblies}
1669 $::form->{assortments}
1671 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1673 =item C<recalc_item_totals %params>
1675 Helper function for calculating the total lastcost and sellprice for assemblies
1676 or assortments according to their items, which are parsed from the current
1679 Is called whenever the qty of an item is changed or items are deleted.
1683 * part_type : 'assortment' or 'assembly' (mandatory)
1685 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1687 Depending on the price_type the lastcost sum or sellprice sum is returned.
1689 Doesn't work for recursive items.
1693 =head1 GET SET INITS
1695 There are get_set_inits for
1703 which parse $::form and automatically create an array of objects.
1705 These inits are used during saving and each time a new element is added.
1709 =item C<init_makemodels>
1711 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1712 $self->part->makemodels, ready to be saved.
1714 Used for saving parts and adding new makemodel rows.
1716 =item C<parse_add_items_to_objects PART_TYPE>
1718 Parses the resulting form from either the part-picker submit or the multi-item
1719 submit, and creates an arrayref of assortment_item or assembly objects, that
1720 can be rendered via C<render_assortment_items_to_html> or
1721 C<render_assembly_items_to_html>.
1723 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1724 Optional param: position (used for numbering and listrow class)
1726 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1728 Takes an array_ref of assortment_items, and generates tables rows ready for
1729 adding to the assortment table. Is used when a part is loaded, or whenever new
1730 assortment items are added.
1732 =item C<parse_form_makemodels>
1734 Makemodels can't just be overwritten, because of the field "lastupdate", that
1735 remembers when the lastcost for that vendor changed the last time.
1737 So the original values are cloned and remembered, so we can compare if lastcost
1738 was changed in $::form, and keep or update lastupdate.
1740 lastcost isn't updated until the first time it was saved with a value, until
1743 Also a boolean "makemodel" needs to be written in parts, depending on whether
1744 makemodel entries exist or not.
1746 We still need init_makemodels for when we open the part for editing.
1756 It should be possible to jump to the edit page in a specific tab
1760 Support callbacks, e.g. creating a new part from within an order, and jumping
1761 back to the order again afterwards.
1765 Support units when adding assembly items or assortment items. Currently the
1766 default unit of the item is always used.
1770 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1771 consists of other assemblies.
1777 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>