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;
155 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
157 if ( $::form->{callback} ) {
158 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
161 # default behaviour after save: reload item, this also resets last_modification!
162 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
166 sub action_save_as_new {
168 $self->action_save(save_as_new=>1);
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');
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 ->no_flash_clear->render();
301 sub action_add_multi_assortment_items {
304 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
305 my $html = $self->render_assortment_items_to_html($item_objects);
307 $self->js->run('kivi.Part.close_picker_dialogs')
308 ->append('#assortment_rows', $html)
309 ->run('kivi.Part.renumber_positions')
310 ->run('kivi.Part.assortment_recalc')
314 sub action_add_multi_assembly_items {
317 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
319 foreach my $item (@{$item_objects}) {
320 my $errstr = validate_assembly($item->part,$self->part);
321 $self->js->flash('error',$errstr) if $errstr;
322 push (@checked_objects,$item) unless $errstr;
325 my $html = $self->render_assembly_items_to_html(\@checked_objects);
327 $self->js->run('kivi.Part.close_picker_dialogs')
328 ->append('#assembly_rows', $html)
329 ->run('kivi.Part.renumber_positions')
330 ->run('kivi.Part.assembly_recalc')
334 sub action_add_assortment_item {
335 my ($self, %params) = @_;
337 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
339 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
341 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
342 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
343 return $self->js->flash('error', t8("This part has already been added."))->render;
346 my $number_of_items = scalar @{$self->assortment_items};
347 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
348 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
350 push(@{$self->assortment_items}, @{$item_objects});
351 my $part = SL::DB::Part->new(part_type => 'assortment');
352 $part->assortment_items(@{$self->assortment_items});
353 my $items_sellprice_sum = $part->items_sellprice_sum;
354 my $items_lastcost_sum = $part->items_lastcost_sum;
355 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
358 ->append('#assortment_rows' , $html) # append in tbody
359 ->val('.add_assortment_item_input' , '')
360 ->run('kivi.Part.focus_last_assortment_input')
361 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
362 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
363 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
364 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
365 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
369 sub action_add_assembly_item {
372 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
374 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
376 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
378 my $duplicate_warning = 0; # duplicates are allowed, just warn
379 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
380 $duplicate_warning++;
383 my $number_of_items = scalar @{$self->assembly_items};
384 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
386 foreach my $item (@{$item_objects}) {
387 my $errstr = validate_assembly($item->part,$self->part);
388 return $self->js->flash('error',$errstr)->render if $errstr;
393 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
395 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
397 push(@{$self->assembly_items}, @{$item_objects});
398 my $part = SL::DB::Part->new(part_type => 'assembly');
399 $part->assemblies(@{$self->assembly_items});
400 my $items_sellprice_sum = $part->items_sellprice_sum;
401 my $items_lastcost_sum = $part->items_lastcost_sum;
402 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
405 ->append('#assembly_rows', $html) # append in tbody
406 ->val('.add_assembly_item_input' , '')
407 ->run('kivi.Part.focus_last_assembly_input')
408 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
409 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
410 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
411 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
412 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
416 sub action_show_multi_items_dialog {
419 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
420 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
421 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
423 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
424 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
425 search_term => $search_term
429 sub action_multi_items_update_result {
430 my $max_count = $::form->{limit};
432 my $count = $_[0]->multi_items_models->count;
435 my $text = escape($::locale->text('No results.'));
436 $_[0]->render($text, { layout => 0 });
437 } elsif ($max_count && $count > $max_count) {
438 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
439 $_[0]->render($text, { layout => 0 });
441 my $multi_items = $_[0]->multi_items_models->get;
442 $_[0]->render('part/_multi_items_result', { layout => 0 },
443 multi_items => $multi_items);
447 sub action_add_makemodel_row {
450 my $vendor_id = $::form->{add_makemodel};
452 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
453 return $self->js->error(t8("No vendor selected or found!"))->render;
455 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
456 $self->js->flash('info', t8("This vendor has already been added."));
459 my $position = scalar @{$self->makemodels} + 1;
461 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
465 sortorder => $position,
466 ) or die "Can't create MakeModel object";
468 my $row_as_html = $self->p->render('part/_makemodel_row',
470 listrow => $position % 2 ? 0 : 1,
473 # after selection focus on the model field in the row that was just added
475 ->append('#makemodel_rows', $row_as_html) # append in tbody
476 ->val('.add_makemodel_input', '')
477 ->run('kivi.Part.focus_last_makemodel_input')
481 sub action_add_customerprice_row {
484 my $customer_id = $::form->{add_customerprice};
486 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
487 or return $self->js->error(t8("No customer selected or found!"))->render;
489 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
490 $self->js->flash('info', t8("This customer has already been added."));
493 my $position = scalar @{ $self->customerprices } + 1;
495 my $cu = SL::DB::PartCustomerPrice->new(
496 customer_id => $customer->id,
497 customer_partnumber => '',
499 sortorder => $position,
500 ) or die "Can't create Customerprice object";
502 my $row_as_html = $self->p->render(
503 'part/_customerprice_row',
504 customerprice => $cu,
505 listrow => $position % 2 ? 0
509 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
510 ->val('.add_customerprice_input', '')
511 ->run('kivi.Part.focus_last_customerprice_input')->render;
514 sub action_reorder_items {
517 my $part_type = $::form->{part_type};
520 partnumber => sub { $_[0]->part->partnumber },
521 description => sub { $_[0]->part->description },
522 qty => sub { $_[0]->qty },
523 sellprice => sub { $_[0]->part->sellprice },
524 lastcost => sub { $_[0]->part->lastcost },
525 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
528 my $method = $sort_keys{$::form->{order_by}};
531 if ($part_type eq 'assortment') {
532 @items = @{ $self->assortment_items };
534 @items = @{ $self->assembly_items };
537 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
538 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
539 if ($::form->{sort_dir}) {
540 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
542 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
545 if ($::form->{sort_dir}) {
546 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
548 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
552 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
555 sub action_warehouse_changed {
558 if ($::form->{warehouse_id} ) {
559 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
560 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
562 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
563 $self->bin($self->warehouse->bins_sorted->[0]);
565 ->html('#bin', $self->build_bin_select)
566 ->focus('#part_bin_id');
567 return $self->js->render;
571 # no warehouse was selected, empty the bin field and reset the id
573 ->val('#part_bin_id', undef)
576 return $self->js->render;
579 sub action_ajax_autocomplete {
580 my ($self, %params) = @_;
582 # if someone types something, and hits enter, assume he entered the full name.
583 # if something matches, treat that as sole match
584 # since we need a second get models instance with different filters for that,
585 # we only modify the original filter temporarily in place
586 if ($::form->{prefer_exact}) {
587 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
588 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
589 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
591 my $exact_models = SL::Controller::Helper::GetModels->new(
594 paginated => { per_page => 2 },
595 with_objects => [ qw(unit_obj classification) ],
598 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
599 $self->parts($exact_matches);
605 value => $_->displayable_name,
606 label => $_->displayable_name,
608 partnumber => $_->partnumber,
609 description => $_->description,
611 part_type => $_->part_type,
613 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
615 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
617 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
620 sub action_test_page {
621 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
624 sub action_part_picker_search {
627 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
628 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
629 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
631 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
634 sub action_part_picker_result {
635 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
641 if ($::request->type eq 'json') {
646 $part_hash = $self->part->as_tree;
647 $part_hash->{cvars} = $self->part->cvar_as_hashref;
650 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
655 sub validate_add_items {
656 scalar @{$::form->{add_items}};
659 sub prepare_assortment_render_vars {
662 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
663 items_lastcost_sum => $self->part->items_lastcost_sum,
664 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
666 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
671 sub prepare_assembly_render_vars {
674 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
676 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
677 items_lastcost_sum => $self->part->items_lastcost_sum,
678 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
680 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
688 check_has_valid_part_type($self->part->part_type);
690 $self->_set_javascript;
691 $self->_setup_form_action_bar;
693 my %title_hash = ( part => t8('Add Part'),
694 assembly => t8('Add Assembly'),
695 service => t8('Add Service'),
696 assortment => t8('Add Assortment'),
701 title => $title_hash{$self->part->part_type},
706 sub _set_javascript {
708 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
709 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
712 sub recalc_item_totals {
713 my ($self, %params) = @_;
715 if ( $params{part_type} eq 'assortment' ) {
716 return 0 unless scalar @{$self->assortment_items};
717 } elsif ( $params{part_type} eq 'assembly' ) {
718 return 0 unless scalar @{$self->assembly_items};
720 carp "can only calculate sum for assortments and assemblies";
723 my $part = SL::DB::Part->new(part_type => $params{part_type});
724 if ( $part->is_assortment ) {
725 $part->assortment_items( @{$self->assortment_items} );
726 if ( $params{price_type} eq 'lastcost' ) {
727 return $part->items_lastcost_sum;
729 if ( $params{pricegroup_id} ) {
730 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
732 return $part->items_sellprice_sum;
735 } elsif ( $part->is_assembly ) {
736 $part->assemblies( @{$self->assembly_items} );
737 if ( $params{price_type} eq 'lastcost' ) {
738 return $part->items_lastcost_sum;
740 return $part->items_sellprice_sum;
745 sub check_part_not_modified {
748 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
755 my $is_new = !$self->part->id;
757 my $params = delete($::form->{part}) || { };
759 delete $params->{id};
760 $self->part->assign_attributes(%{ $params});
761 $self->part->bin_id(undef) unless $self->part->warehouse_id;
763 $self->normalize_text_blocks;
765 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
766 # will be the case for used assortments when saving, or when a used assortment
768 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
769 $self->part->assortment_items([]);
770 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
773 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
774 $self->part->assemblies([]); # completely rewrite assortments each time
775 $self->part->add_assemblies( @{ $self->assembly_items } );
778 $self->part->translations([]);
779 $self->parse_form_translations;
781 $self->part->prices([]);
782 $self->parse_form_prices;
784 $self->parse_form_customerprices;
785 $self->parse_form_makemodels;
788 sub parse_form_prices {
790 # only save prices > 0
791 my $prices = delete($::form->{prices}) || [];
792 foreach my $price ( @{$prices} ) {
793 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
794 next unless $sellprice > 0; # skip negative prices as well
795 my $p = SL::DB::Price->new(parts_id => $self->part->id,
796 pricegroup_id => $price->{pricegroup_id},
799 $self->part->add_prices($p);
803 sub parse_form_translations {
805 # don't add empty translations
806 my $translations = delete($::form->{translations}) || [];
807 foreach my $translation ( @{$translations} ) {
808 next unless $translation->{translation};
809 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
810 $self->part->add_translations( $translation );
814 sub parse_form_makemodels {
818 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
819 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
822 $self->part->makemodels([]);
825 my $makemodels = delete($::form->{makemodels}) || [];
826 foreach my $makemodel ( @{$makemodels} ) {
827 next unless $makemodel->{make};
829 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
831 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
832 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
834 make => $makemodel->{make},
835 model => $makemodel->{model} || '',
836 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
837 sortorder => $position,
839 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
840 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
841 # don't change lastupdate
842 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
843 # new makemodel, no lastcost entered, leave lastupdate empty
844 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
845 # lastcost hasn't changed, use original lastupdate
846 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
848 $mm->lastupdate(DateTime->now);
850 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
851 $self->part->add_makemodels($mm);
855 sub parse_form_customerprices {
858 my $customerprices_map;
859 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
860 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
863 $self->part->customerprices([]);
866 my $customerprices = delete($::form->{customerprices}) || [];
867 foreach my $customerprice ( @{$customerprices} ) {
868 next unless $customerprice->{customer_id};
870 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
872 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
873 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
875 customer_id => $customerprice->{customer_id},
876 customer_partnumber => $customerprice->{customer_partnumber} || '',
877 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
878 sortorder => $position,
880 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
881 # lastupdate isn't set, original price is 0 and new lastcost is 0
882 # don't change lastupdate
883 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
884 # new customerprice, no lastcost entered, leave lastupdate empty
885 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
886 # price hasn't changed, use original lastupdate
887 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
889 $cu->lastupdate(DateTime->now);
891 $self->part->add_customerprices($cu);
895 sub build_bin_select {
896 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
897 title_key => 'description',
898 default => $_[0]->bin->id,
903 # get_set_inits for partpicker
906 if ($::form->{no_paginate}) {
907 $_[0]->models->disable_plugin('paginated');
913 # get_set_inits for part controller
917 # used by edit, save, delete and add
919 if ( $::form->{part}{id} ) {
920 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
921 } elsif ( $::form->{id} ) {
922 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
924 die "part_type missing" unless $::form->{part}{part_type};
925 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
931 return $self->part->orphaned;
937 SL::Controller::Helper::GetModels->new(
944 partnumber => t8('Partnumber'),
945 description => t8('Description'),
947 with_objects => [ qw(unit_obj classification) ],
956 sub init_assortment_items {
957 # this init is used while saving and whenever assortments change dynamically
961 my $assortment_items = delete($::form->{assortment_items}) || [];
962 foreach my $assortment_item ( @{$assortment_items} ) {
963 next unless $assortment_item->{parts_id};
965 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
966 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
967 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
968 charge => $assortment_item->{charge},
969 unit => $assortment_item->{unit} || $part->unit,
970 position => $position,
978 sub init_makemodels {
982 my @makemodel_array = ();
983 my $makemodels = delete($::form->{makemodels}) || [];
985 foreach my $makemodel ( @{$makemodels} ) {
986 next unless $makemodel->{make};
988 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
989 id => $makemodel->{id},
990 make => $makemodel->{make},
991 model => $makemodel->{model} || '',
992 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
993 sortorder => $position,
994 ) or die "Can't create mm";
995 # $mm->id($makemodel->{id}) if $makemodel->{id};
996 push(@makemodel_array, $mm);
998 return \@makemodel_array;
1001 sub init_customerprices {
1005 my @customerprice_array = ();
1006 my $customerprices = delete($::form->{customerprices}) || [];
1008 foreach my $customerprice ( @{$customerprices} ) {
1009 next unless $customerprice->{customer_id};
1011 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1012 id => $customerprice->{id},
1013 customer_partnumber => $customerprice->{customer_partnumber},
1014 customer_id => $customerprice->{customer_id} || '',
1015 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1016 sortorder => $position,
1017 ) or die "Can't create cu";
1018 # $cu->id($customerprice->{id}) if $customerprice->{id};
1019 push(@customerprice_array, $cu);
1021 return \@customerprice_array;
1024 sub init_assembly_items {
1028 my $assembly_items = delete($::form->{assembly_items}) || [];
1029 foreach my $assembly_item ( @{$assembly_items} ) {
1030 next unless $assembly_item->{parts_id};
1032 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1033 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1034 bom => $assembly_item->{bom},
1035 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1036 position => $position,
1043 sub init_all_warehouses {
1045 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1048 sub init_all_languages {
1049 SL::DB::Manager::Language->get_all_sorted;
1052 sub init_all_partsgroups {
1054 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1057 sub init_all_buchungsgruppen {
1059 if ( $self->part->orphaned ) {
1060 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1062 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1066 sub init_shops_not_assigned {
1069 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1070 if ( @used_shop_ids ) {
1071 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1074 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1078 sub init_all_units {
1080 if ( $self->part->orphaned ) {
1081 return SL::DB::Manager::Unit->get_all_sorted;
1083 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1087 sub init_all_payment_terms {
1089 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1092 sub init_all_price_factors {
1093 SL::DB::Manager::PriceFactor->get_all_sorted;
1096 sub init_all_pricegroups {
1097 SL::DB::Manager::Pricegroup->get_all_sorted;
1100 # model used to filter/display the parts in the multi-items dialog
1101 sub init_multi_items_models {
1102 SL::Controller::Helper::GetModels->new(
1103 controller => $_[0],
1105 with_objects => [ qw(unit_obj partsgroup classification) ],
1106 disable_plugin => 'paginated',
1107 source => $::form->{multi_items},
1113 partnumber => t8('Partnumber'),
1114 description => t8('Description')}
1118 sub init_parts_classification_filter {
1119 return [] unless $::form->{parts_classification_type};
1121 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1122 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1124 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1127 # simple checks to run on $::form before saving
1129 sub form_check_part_description_exists {
1132 return 1 if $::form->{part}{description};
1134 $self->js->flash('error', t8('Part Description missing!'))
1135 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1136 ->focus('#part_description');
1140 sub form_check_assortment_items_exist {
1143 return 1 unless $::form->{part}{part_type} eq 'assortment';
1144 # skip item check for existing assortments that have been used
1145 return 1 if ($self->part->id and !$self->part->orphaned);
1147 # new or orphaned parts must have items in $::form->{assortment_items}
1148 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1149 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1150 ->focus('#add_assortment_item_name')
1151 ->flash('error', t8('The assortment doesn\'t have any items.'));
1157 sub form_check_assortment_items_unique {
1160 return 1 unless $::form->{part}{part_type} eq 'assortment';
1162 my %duplicate_elements;
1164 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1165 $duplicate_elements{$_}++ if $count{$_}++;
1168 if ( keys %duplicate_elements ) {
1169 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1170 ->flash('error', t8('There are duplicate assortment items'));
1176 sub form_check_assembly_items_exist {
1179 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1181 # skip item check for existing assembly that have been used
1182 return 1 if ($self->part->id and !$self->part->orphaned);
1184 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1185 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1186 ->focus('#add_assembly_item_name')
1187 ->flash('error', t8('The assembly doesn\'t have any items.'));
1193 sub form_check_partnumber_is_unique {
1196 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1197 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1199 $self->js->flash('error', t8('The partnumber already exists!'))
1200 ->focus('#part_description');
1207 # general checking functions
1210 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1216 $self->form_check_part_description_exists || return 0;
1217 $self->form_check_assortment_items_exist || return 0;
1218 $self->form_check_assortment_items_unique || return 0;
1219 $self->form_check_assembly_items_exist || return 0;
1220 $self->form_check_partnumber_is_unique || return 0;
1225 sub check_has_valid_part_type {
1226 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1230 sub normalize_text_blocks {
1233 # check if feature is enabled (select normalize_part_descriptions from defaults)
1234 return unless ($::instance_conf->get_normalize_part_descriptions);
1237 foreach (qw(description)) {
1238 $self->part->{$_} =~ s/\s+$//s;
1239 $self->part->{$_} =~ s/^\s+//s;
1240 $self->part->{$_} =~ s/ {2,}/ /g;
1242 # html block (caveat: can be circumvented by using bold or italics)
1243 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1244 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1248 sub render_assortment_items_to_html {
1249 my ($self, $assortment_items, $number_of_items) = @_;
1251 my $position = $number_of_items + 1;
1253 foreach my $ai (@$assortment_items) {
1254 $html .= $self->p->render('part/_assortment_row',
1255 PART => $self->part,
1256 orphaned => $self->orphaned,
1258 listrow => $position % 2 ? 1 : 0,
1259 position => $position, # for legacy assemblies
1266 sub render_assembly_items_to_html {
1267 my ($self, $assembly_items, $number_of_items) = @_;
1269 my $position = $number_of_items + 1;
1271 foreach my $ai (@{$assembly_items}) {
1272 $html .= $self->p->render('part/_assembly_row',
1273 PART => $self->part,
1274 orphaned => $self->orphaned,
1276 listrow => $position % 2 ? 1 : 0,
1277 position => $position, # for legacy assemblies
1284 sub parse_add_items_to_objects {
1285 my ($self, %params) = @_;
1286 my $part_type = $params{part_type};
1287 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1288 my $position = $params{position} || 1;
1290 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1293 foreach my $item ( @add_items ) {
1294 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1296 if ( $part_type eq 'assortment' ) {
1297 $ai = SL::DB::AssortmentItem->new(part => $part,
1298 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1299 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1300 position => $position,
1301 ) or die "Can't create AssortmentItem from item";
1302 } elsif ( $part_type eq 'assembly' ) {
1303 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1304 # id => $self->assembly->id, # will be set on save
1305 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1306 bom => 0, # default when adding: no bom
1307 position => $position,
1310 die "part_type must be assortment or assembly";
1312 push(@item_objects, $ai);
1316 return \@item_objects;
1319 sub _setup_form_action_bar {
1322 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1323 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1325 for my $bar ($::request->layout->get('actionbar')) {
1330 call => [ 'kivi.Part.save' ],
1331 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1335 call => [ 'kivi.Part.use_as_new' ],
1336 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1337 : !$may_edit ? t8('You do not have the permissions to access this function.')
1340 ], # end of combobox "Save"
1344 call => [ 'kivi.Part.delete' ],
1345 confirm => t8('Do you really want to delete this object?'),
1346 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1347 : !$may_edit ? t8('You do not have the permissions to access this function.')
1348 : !$self->part->orphaned ? t8('This object has already been used.')
1349 : $used_in_pricerules ? t8('This object is used in price rules.')
1357 call => [ 'kivi.Part.open_history_popup' ],
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.')
1374 SL::Controller::Part - Part CRUD controller
1378 Controller for adding/editing/saving/deleting parts.
1380 All the relations are loaded at once and saving the part, adding a history
1381 entry and saving CVars happens inside one transaction. When saving the old
1382 relations are deleted and written as new to the database.
1384 Relations for parts:
1392 =item assembly items
1394 =item assortment items
1402 There are 4 different part types:
1408 The "default" part type.
1410 inventory_accno_id is set.
1414 Services can't be stocked.
1416 inventory_accno_id isn't set.
1420 Assemblies consist of other parts, services, assemblies or assortments. They
1421 aren't meant to be bought, only sold. To add assemblies to stock you typically
1422 have to make them, which reduces the stock by its respective components. Once
1423 an assembly item has been created there is currently no way to "disassemble" it
1424 again. An assembly item can appear several times in one assembly. An assmbly is
1425 sold as one item with a defined sellprice and lastcost. If the component prices
1426 change the assortment price remains the same. The assembly items may be printed
1427 in a record if the item's "bom" is set.
1431 Similar to assembly, but each assortment item may only appear once per
1432 assortment. When selling an assortment the assortment items are added to the
1433 record together with the assortment, which is added with sellprice 0.
1435 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1436 determined by the sum of the current assortment item prices when the assortment
1437 is added to a record. This also means that price rules and customer discounts
1438 will be applied to the assortment items.
1440 Once the assortment items have been added they may be modified or deleted, just
1441 as if they had been added manually, the individual assortment items aren't
1442 linked to the assortment or the other assortment items in any way.
1450 =item C<action_add_part>
1452 =item C<action_add_service>
1454 =item C<action_add_assembly>
1456 =item C<action_add_assortment>
1458 =item C<action_add PART_TYPE>
1460 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1461 parameter part_type as an action. Example:
1463 controller.pl?action=Part/add&part_type=service
1465 =item C<action_add_from_record>
1467 When adding new items to records they can be created on the fly if the entered
1468 partnumber or description doesn't exist yet. After being asked what part type
1469 the new item should have the user is redirected to the correct edit page.
1471 Depending on whether the item was added from a sales or a purchase record, only
1472 the relevant part classifications should be selectable for new item, so this
1473 parameter is passed on via a hidden parts_classification_type in the new_item
1476 =item C<action_save>
1478 Saves the current part and then reloads the edit page for the part.
1480 =item C<action_use_as_new>
1482 Takes the information from the current part, plus any modifications made on the
1483 page, and creates a new edit page that is ready to be saved. The partnumber is
1484 set empty, so a new partnumber from the number range will be used if the user
1485 doesn't enter one manually.
1487 Unsaved changes to the original part aren't updated.
1489 The part type cannot be changed in this way.
1491 =item C<action_delete>
1493 Deletes the current part and then redirects to the main page, there is no
1496 The delete button only appears if the part is 'orphaned', according to
1497 SL::DB::Part orphaned.
1499 The part can't be deleted if it appears in invoices, orders, delivery orders,
1500 the inventory, or is part of an assembly or assortment.
1502 If the part is deleted its relations prices, makdemodel, assembly,
1503 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1505 Before this controller items that appeared in inventory didn't count as
1506 orphaned and could be deleted and the inventory entries were also deleted, this
1507 "feature" hasn't been implemented.
1509 =item C<action_edit part.id>
1511 Load and display a part for editing.
1513 controller.pl?action=Part/edit&part.id=12345
1515 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1519 =head1 BUTTON ACTIONS
1525 Opens a popup displaying all the history entries. Once a new history controller
1526 is written the button could link there instead, with the part already selected.
1534 =item C<action_update_item_totals>
1536 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1537 amount of an item changes. The sum of all sellprices and lastcosts is
1538 calculated and the totals updated. Uses C<recalc_item_totals>.
1540 =item C<action_add_assortment_item>
1542 Adds a new assortment item from a part picker seleciton to the assortment item list
1544 If the item already exists in the assortment the item isn't added and a Flash
1547 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1548 after adding each new item, add the new object to the item objects that were
1549 already parsed, calculate totals via a dummy part then update the row and the
1552 =item C<action_add_assembly_item>
1554 Adds a new assembly item from a part picker seleciton to the assembly item list
1556 If the item already exists in the assembly a flash info is generated, but the
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_multi_assortment_items>
1566 Parses the items to be added from the form generated by the multi input and
1567 appends the html of the tr-rows to the assortment item table. Afterwards all
1568 assortment items are renumbered and the sums recalculated via
1569 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1571 =item C<action_add_multi_assembly_items>
1573 Parses the items to be added from the form generated by the multi input and
1574 appends the html of the tr-rows to the assembly item table. Afterwards all
1575 assembly items are renumbered and the sums recalculated via
1576 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1578 =item C<action_show_multi_items_dialog>
1580 =item C<action_multi_items_update_result>
1582 =item C<action_add_makemodel_row>
1584 Add a new makemodel row with the vendor that was selected via the vendor
1587 Checks the already existing makemodels and warns if a row with that vendor
1588 already exists. Currently it is possible to have duplicate vendor rows.
1590 =item C<action_reorder_items>
1592 Sorts the item table for assembly or assortment items.
1594 =item C<action_warehouse_changed>
1598 =head1 ACTIONS part picker
1602 =item C<action_ajax_autocomplete>
1604 =item C<action_test_page>
1606 =item C<action_part_picker_search>
1608 =item C<action_part_picker_result>
1610 =item C<action_show>
1620 Calls some simple checks that test the submitted $::form for obvious errors.
1621 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1623 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1624 some cases extra actions are taken, e.g. if the part description is missing the
1625 basic data tab is selected and the description input field is focussed.
1631 =item C<form_check_part_description_exists>
1633 =item C<form_check_assortment_items_exist>
1635 =item C<form_check_assortment_items_unique>
1637 =item C<form_check_assembly_items_exist>
1639 =item C<form_check_partnumber_is_unique>
1643 =head1 HELPER FUNCTIONS
1649 When submitting the form for saving, parses the transmitted form. Expects the
1653 $::form->{makemodels}
1654 $::form->{translations}
1656 $::form->{assemblies}
1657 $::form->{assortments}
1659 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1661 =item C<recalc_item_totals %params>
1663 Helper function for calculating the total lastcost and sellprice for assemblies
1664 or assortments according to their items, which are parsed from the current
1667 Is called whenever the qty of an item is changed or items are deleted.
1671 * part_type : 'assortment' or 'assembly' (mandatory)
1673 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1675 Depending on the price_type the lastcost sum or sellprice sum is returned.
1677 Doesn't work for recursive items.
1681 =head1 GET SET INITS
1683 There are get_set_inits for
1691 which parse $::form and automatically create an array of objects.
1693 These inits are used during saving and each time a new element is added.
1697 =item C<init_makemodels>
1699 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1700 $self->part->makemodels, ready to be saved.
1702 Used for saving parts and adding new makemodel rows.
1704 =item C<parse_add_items_to_objects PART_TYPE>
1706 Parses the resulting form from either the part-picker submit or the multi-item
1707 submit, and creates an arrayref of assortment_item or assembly objects, that
1708 can be rendered via C<render_assortment_items_to_html> or
1709 C<render_assembly_items_to_html>.
1711 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1712 Optional param: position (used for numbering and listrow class)
1714 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1716 Takes an array_ref of assortment_items, and generates tables rows ready for
1717 adding to the assortment table. Is used when a part is loaded, or whenever new
1718 assortment items are added.
1720 =item C<parse_form_makemodels>
1722 Makemodels can't just be overwritten, because of the field "lastupdate", that
1723 remembers when the lastcost for that vendor changed the last time.
1725 So the original values are cloned and remembered, so we can compare if lastcost
1726 was changed in $::form, and keep or update lastupdate.
1728 lastcost isn't updated until the first time it was saved with a value, until
1731 Also a boolean "makemodel" needs to be written in parts, depending on whether
1732 makemodel entries exist or not.
1734 We still need init_makemodels for when we open the part for editing.
1744 It should be possible to jump to the edit page in a specific tab
1748 Support callbacks, e.g. creating a new part from within an order, and jumping
1749 back to the order again afterwards.
1753 Support units when adding assembly items or assortment items. Currently the
1754 default unit of the item is always used.
1758 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1759 consists of other assemblies.
1765 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>