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 SL::Helper::Flash;
19 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
21 use SL::MoreCommon qw(save_form);
23 use SL::Presenter::EscapedText qw(escape is_escaped);
24 use SL::Presenter::Tag qw(select_tag);
26 use Rose::Object::MakeMethods::Generic (
27 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
28 makemodels shops_not_assigned
31 assortment assortment_items assembly assembly_items
32 all_pricegroups all_translations all_partsgroups all_units
33 all_buchungsgruppen all_payment_terms all_warehouses
34 parts_classification_filter
35 all_languages all_units all_price_factors) ],
36 'scalar' => [ qw(warehouse bin) ],
40 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
41 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
43 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
45 # actions for editing parts
48 my ($self, %params) = @_;
50 $self->part( SL::DB::Part->new_part );
54 sub action_add_service {
55 my ($self, %params) = @_;
57 $self->part( SL::DB::Part->new_service );
61 sub action_add_assembly {
62 my ($self, %params) = @_;
64 $self->part( SL::DB::Part->new_assembly );
68 sub action_add_assortment {
69 my ($self, %params) = @_;
71 $self->part( SL::DB::Part->new_assortment );
75 sub action_add_from_record {
78 check_has_valid_part_type($::form->{part}{part_type});
80 die 'parts_classification_type must be "sales" or "purchases"'
81 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
90 check_has_valid_part_type($::form->{part_type});
92 $self->action_add_part if $::form->{part_type} eq 'part';
93 $self->action_add_service if $::form->{part_type} eq 'service';
94 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
95 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
99 my ($self, %params) = @_;
101 # checks that depend only on submitted $::form
102 $self->check_form or return $self->js->render;
104 my $is_new = !$self->part->id; # $ part gets loaded here
106 # check that the part hasn't been modified
108 $self->check_part_not_modified or
109 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;
113 && $::form->{part}{partnumber}
114 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
116 return $self->js->error(t8('The partnumber is already being used'))->render;
121 my @errors = $self->part->validate;
122 return $self->js->error(@errors)->render if @errors;
124 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
125 $self->part->db->with_transaction(sub {
127 if ( $params{save_as_new} ) {
128 $self->part( $self->part->clone_and_reset_deep );
129 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
132 $self->part->save(cascade => 1);
134 SL::DB::History->new(
135 trans_id => $self->part->id,
136 snumbers => 'partnumber_' . $self->part->partnumber,
137 employee_id => SL::DB::Manager::Employee->current->id,
142 CVar->save_custom_variables(
143 dbh => $self->part->db->dbh,
145 trans_id => $self->part->id,
146 variables => $::form, # $::form->{cvar} would be nicer
151 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
154 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
156 if ( $::form->{callback} ) {
157 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
160 # default behaviour after save: reload item, this also resets last_modification!
161 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
165 sub action_save_as_new {
167 $self->action_save(save_as_new=>1);
173 my $db = $self->part->db; # $self->part has a get_set_init on $::form
175 my $partnumber = $self->part->partnumber; # remember for history log
180 # delete part, together with relationships that don't already
181 # have an ON DELETE CASCADE, e.g. makemodel and translation.
182 $self->part->delete(cascade => 1);
184 SL::DB::History->new(
185 trans_id => $self->part->id,
186 snumbers => 'partnumber_' . $partnumber,
187 employee_id => SL::DB::Manager::Employee->current->id,
189 addition => 'DELETED',
192 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
194 flash_later('info', t8('The item has been deleted.'));
195 if ( $::form->{callback} ) {
196 $self->redirect_to($::form->unescape($::form->{callback}));
198 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
202 sub action_use_as_new {
203 my ($self, %params) = @_;
205 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
206 $::form->{oldpartnumber} = $oldpart->partnumber;
208 $self->part($oldpart->clone_and_reset_deep);
210 $self->part->partnumber(undef);
216 my ($self, %params) = @_;
222 my ($self, %params) = @_;
224 $self->_set_javascript;
225 $self->_setup_form_action_bar;
227 my (%assortment_vars, %assembly_vars);
228 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
229 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
231 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
233 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
234 if (scalar @{ $params{CUSTOM_VARIABLES} });
236 my %title_hash = ( part => t8('Edit Part'),
237 assembly => t8('Edit Assembly'),
238 service => t8('Edit Service'),
239 assortment => t8('Edit Assortment'),
242 $self->part->prices([]) unless $self->part->prices;
243 $self->part->translations([]) unless $self->part->translations;
247 title => $title_hash{$self->part->part_type},
250 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
251 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
252 oldpartnumber => $::form->{oldpartnumber},
253 old_id => $::form->{old_id},
261 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
262 $_[0]->render('part/history', { layout => 0 },
263 history_entries => $history_entries);
266 sub action_update_item_totals {
269 my $part_type = $::form->{part_type};
270 die unless $part_type =~ /^(assortment|assembly)$/;
272 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
273 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
275 my $sum_diff = $sellprice_sum-$lastcost_sum;
278 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
279 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
280 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
281 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
282 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
283 ->no_flash_clear->render();
286 sub action_add_multi_assortment_items {
289 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
290 my $html = $self->render_assortment_items_to_html($item_objects);
292 $self->js->run('kivi.Part.close_picker_dialogs')
293 ->append('#assortment_rows', $html)
294 ->run('kivi.Part.renumber_positions')
295 ->run('kivi.Part.assortment_recalc')
299 sub action_add_multi_assembly_items {
302 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
304 foreach my $item (@{$item_objects}) {
305 my $errstr = validate_assembly($item->part,$self->part);
306 $self->js->flash('error',$errstr) if $errstr;
307 push (@checked_objects,$item) unless $errstr;
310 my $html = $self->render_assembly_items_to_html(\@checked_objects);
312 $self->js->run('kivi.Part.close_picker_dialogs')
313 ->append('#assembly_rows', $html)
314 ->run('kivi.Part.renumber_positions')
315 ->run('kivi.Part.assembly_recalc')
319 sub action_add_assortment_item {
320 my ($self, %params) = @_;
322 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
324 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
326 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
327 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
328 return $self->js->flash('error', t8("This part has already been added."))->render;
331 my $number_of_items = scalar @{$self->assortment_items};
332 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
333 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
335 push(@{$self->assortment_items}, @{$item_objects});
336 my $part = SL::DB::Part->new(part_type => 'assortment');
337 $part->assortment_items(@{$self->assortment_items});
338 my $items_sellprice_sum = $part->items_sellprice_sum;
339 my $items_lastcost_sum = $part->items_lastcost_sum;
340 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
343 ->append('#assortment_rows' , $html) # append in tbody
344 ->val('.add_assortment_item_input' , '')
345 ->run('kivi.Part.focus_last_assortment_input')
346 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
347 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
348 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
349 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
350 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
354 sub action_add_assembly_item {
357 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
359 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
361 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
363 my $duplicate_warning = 0; # duplicates are allowed, just warn
364 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
365 $duplicate_warning++;
368 my $number_of_items = scalar @{$self->assembly_items};
369 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
371 foreach my $item (@{$item_objects}) {
372 my $errstr = validate_assembly($item->part,$self->part);
373 return $self->js->flash('error',$errstr)->render if $errstr;
378 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
380 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
382 push(@{$self->assembly_items}, @{$item_objects});
383 my $part = SL::DB::Part->new(part_type => 'assembly');
384 $part->assemblies(@{$self->assembly_items});
385 my $items_sellprice_sum = $part->items_sellprice_sum;
386 my $items_lastcost_sum = $part->items_lastcost_sum;
387 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
390 ->append('#assembly_rows', $html) # append in tbody
391 ->val('.add_assembly_item_input' , '')
392 ->run('kivi.Part.focus_last_assembly_input')
393 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
394 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
395 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
396 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
397 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
401 sub action_show_multi_items_dialog {
404 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
405 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
406 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
408 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
409 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
410 search_term => $search_term
414 sub action_multi_items_update_result {
417 my $count = $_[0]->multi_items_models->count;
420 my $text = escape($::locale->text('No results.'));
421 $_[0]->render($text, { layout => 0 });
422 } elsif ($count > $max_count) {
423 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
424 $_[0]->render($text, { layout => 0 });
426 my $multi_items = $_[0]->multi_items_models->get;
427 $_[0]->render('part/_multi_items_result', { layout => 0 },
428 multi_items => $multi_items);
432 sub action_add_makemodel_row {
435 my $vendor_id = $::form->{add_makemodel};
437 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
438 return $self->js->error(t8("No vendor selected or found!"))->render;
440 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
441 $self->js->flash('info', t8("This vendor has already been added."));
444 my $position = scalar @{$self->makemodels} + 1;
446 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
450 sortorder => $position,
451 ) or die "Can't create MakeModel object";
453 my $row_as_html = $self->p->render('part/_makemodel_row',
455 listrow => $position % 2 ? 0 : 1,
458 # after selection focus on the model field in the row that was just added
460 ->append('#makemodel_rows', $row_as_html) # append in tbody
461 ->val('.add_makemodel_input', '')
462 ->run('kivi.Part.focus_last_makemodel_input')
466 sub action_add_customerprice_row {
469 my $customer_id = $::form->{add_customerprice};
471 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
472 or return $self->js->error(t8("No customer selected or found!"))->render;
474 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
475 $self->js->flash('info', t8("This customer has already been added."));
478 my $position = scalar @{ $self->customerprices } + 1;
480 my $cu = SL::DB::PartCustomerPrice->new(
481 customer_id => $customer->id,
482 customer_partnumber => '',
484 sortorder => $position,
485 ) or die "Can't create Customerprice object";
487 my $row_as_html = $self->p->render(
488 'part/_customerprice_row',
489 customerprice => $cu,
490 listrow => $position % 2 ? 0
494 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
495 ->val('.add_customerprice_input', '')
496 ->run('kivi.Part.focus_last_customerprice_input')->render;
499 sub action_reorder_items {
502 my $part_type = $::form->{part_type};
505 partnumber => sub { $_[0]->part->partnumber },
506 description => sub { $_[0]->part->description },
507 qty => sub { $_[0]->qty },
508 sellprice => sub { $_[0]->part->sellprice },
509 lastcost => sub { $_[0]->part->lastcost },
510 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
513 my $method = $sort_keys{$::form->{order_by}};
516 if ($part_type eq 'assortment') {
517 @items = @{ $self->assortment_items };
519 @items = @{ $self->assembly_items };
522 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
523 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
524 if ($::form->{sort_dir}) {
525 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
527 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
530 if ($::form->{sort_dir}) {
531 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
533 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
537 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
540 sub action_warehouse_changed {
543 if ($::form->{warehouse_id} ) {
544 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
545 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
547 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
548 $self->bin($self->warehouse->bins->[0]);
550 ->html('#bin', $self->build_bin_select)
551 ->focus('#part_bin_id');
552 return $self->js->render;
556 # no warehouse was selected, empty the bin field and reset the id
558 ->val('#part_bin_id', undef)
561 return $self->js->render;
564 sub action_ajax_autocomplete {
565 my ($self, %params) = @_;
567 # if someone types something, and hits enter, assume he entered the full name.
568 # if something matches, treat that as sole match
569 # since we need a second get models instance with different filters for that,
570 # we only modify the original filter temporarily in place
571 if ($::form->{prefer_exact}) {
572 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
573 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
574 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
576 my $exact_models = SL::Controller::Helper::GetModels->new(
579 paginated => { per_page => 2 },
580 with_objects => [ qw(unit_obj classification) ],
583 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
584 $self->parts($exact_matches);
590 value => $_->displayable_name,
591 label => $_->displayable_name,
593 partnumber => $_->partnumber,
594 description => $_->description,
596 part_type => $_->part_type,
598 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
600 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
602 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
605 sub action_test_page {
606 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
609 sub action_part_picker_search {
612 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
613 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
614 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
616 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
619 sub action_part_picker_result {
620 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
626 if ($::request->type eq 'json') {
631 $part_hash = $self->part->as_tree;
632 $part_hash->{cvars} = $self->part->cvar_as_hashref;
635 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
640 sub validate_add_items {
641 scalar @{$::form->{add_items}};
644 sub prepare_assortment_render_vars {
647 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
648 items_lastcost_sum => $self->part->items_lastcost_sum,
649 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
651 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
656 sub prepare_assembly_render_vars {
659 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
661 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
662 items_lastcost_sum => $self->part->items_lastcost_sum,
663 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
665 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
673 check_has_valid_part_type($self->part->part_type);
675 $self->_set_javascript;
676 $self->_setup_form_action_bar;
678 my %title_hash = ( part => t8('Add Part'),
679 assembly => t8('Add Assembly'),
680 service => t8('Add Service'),
681 assortment => t8('Add Assortment'),
686 title => $title_hash{$self->part->part_type},
691 sub _set_javascript {
693 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
694 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
697 sub recalc_item_totals {
698 my ($self, %params) = @_;
700 if ( $params{part_type} eq 'assortment' ) {
701 return 0 unless scalar @{$self->assortment_items};
702 } elsif ( $params{part_type} eq 'assembly' ) {
703 return 0 unless scalar @{$self->assembly_items};
705 carp "can only calculate sum for assortments and assemblies";
708 my $part = SL::DB::Part->new(part_type => $params{part_type});
709 if ( $part->is_assortment ) {
710 $part->assortment_items( @{$self->assortment_items} );
711 if ( $params{price_type} eq 'lastcost' ) {
712 return $part->items_lastcost_sum;
714 if ( $params{pricegroup_id} ) {
715 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
717 return $part->items_sellprice_sum;
720 } elsif ( $part->is_assembly ) {
721 $part->assemblies( @{$self->assembly_items} );
722 if ( $params{price_type} eq 'lastcost' ) {
723 return $part->items_lastcost_sum;
725 return $part->items_sellprice_sum;
730 sub check_part_not_modified {
733 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
740 my $is_new = !$self->part->id;
742 my $params = delete($::form->{part}) || { };
744 delete $params->{id};
745 $self->part->assign_attributes(%{ $params});
746 $self->part->bin_id(undef) unless $self->part->warehouse_id;
748 $self->normalize_text_blocks;
750 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
751 # will be the case for used assortments when saving, or when a used assortment
753 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
754 $self->part->assortment_items([]);
755 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
758 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
759 $self->part->assemblies([]); # completely rewrite assortments each time
760 $self->part->add_assemblies( @{ $self->assembly_items } );
763 $self->part->translations([]);
764 $self->parse_form_translations;
766 $self->part->prices([]);
767 $self->parse_form_prices;
769 $self->parse_form_customerprices;
770 $self->parse_form_makemodels;
773 sub parse_form_prices {
775 # only save prices > 0
776 my $prices = delete($::form->{prices}) || [];
777 foreach my $price ( @{$prices} ) {
778 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
779 next unless $sellprice > 0; # skip negative prices as well
780 my $p = SL::DB::Price->new(parts_id => $self->part->id,
781 pricegroup_id => $price->{pricegroup_id},
784 $self->part->add_prices($p);
788 sub parse_form_translations {
790 # don't add empty translations
791 my $translations = delete($::form->{translations}) || [];
792 foreach my $translation ( @{$translations} ) {
793 next unless $translation->{translation};
794 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
795 $self->part->add_translations( $translation );
799 sub parse_form_makemodels {
803 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
804 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
807 $self->part->makemodels([]);
810 my $makemodels = delete($::form->{makemodels}) || [];
811 foreach my $makemodel ( @{$makemodels} ) {
812 next unless $makemodel->{make};
814 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
816 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
817 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
819 make => $makemodel->{make},
820 model => $makemodel->{model} || '',
821 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
822 sortorder => $position,
824 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
825 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
826 # don't change lastupdate
827 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
828 # new makemodel, no lastcost entered, leave lastupdate empty
829 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
830 # lastcost hasn't changed, use original lastupdate
831 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
833 $mm->lastupdate(DateTime->now);
835 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
836 $self->part->add_makemodels($mm);
840 sub parse_form_customerprices {
843 my $customerprices_map;
844 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
845 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
848 $self->part->customerprices([]);
851 my $customerprices = delete($::form->{customerprices}) || [];
852 foreach my $customerprice ( @{$customerprices} ) {
853 next unless $customerprice->{customer_id};
855 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
857 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
858 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
860 customer_id => $customerprice->{customer_id},
861 customer_partnumber => $customerprice->{customer_partnumber} || '',
862 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
863 sortorder => $position,
865 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
866 # lastupdate isn't set, original price is 0 and new lastcost is 0
867 # don't change lastupdate
868 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
869 # new customerprice, no lastcost entered, leave lastupdate empty
870 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
871 # price hasn't changed, use original lastupdate
872 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
874 $cu->lastupdate(DateTime->now);
876 $self->part->add_customerprices($cu);
880 sub build_bin_select {
881 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
882 title_key => 'description',
883 default => $_[0]->bin->id,
888 # get_set_inits for partpicker
891 if ($::form->{no_paginate}) {
892 $_[0]->models->disable_plugin('paginated');
898 # get_set_inits for part controller
902 # used by edit, save, delete and add
904 if ( $::form->{part}{id} ) {
905 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
907 die "part_type missing" unless $::form->{part}{part_type};
908 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
914 return $self->part->orphaned;
920 SL::Controller::Helper::GetModels->new(
927 partnumber => t8('Partnumber'),
928 description => t8('Description'),
930 with_objects => [ qw(unit_obj classification) ],
939 sub init_assortment_items {
940 # this init is used while saving and whenever assortments change dynamically
944 my $assortment_items = delete($::form->{assortment_items}) || [];
945 foreach my $assortment_item ( @{$assortment_items} ) {
946 next unless $assortment_item->{parts_id};
948 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
949 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
950 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
951 charge => $assortment_item->{charge},
952 unit => $assortment_item->{unit} || $part->unit,
953 position => $position,
961 sub init_makemodels {
965 my @makemodel_array = ();
966 my $makemodels = delete($::form->{makemodels}) || [];
968 foreach my $makemodel ( @{$makemodels} ) {
969 next unless $makemodel->{make};
971 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
972 id => $makemodel->{id},
973 make => $makemodel->{make},
974 model => $makemodel->{model} || '',
975 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
976 sortorder => $position,
977 ) or die "Can't create mm";
978 # $mm->id($makemodel->{id}) if $makemodel->{id};
979 push(@makemodel_array, $mm);
981 return \@makemodel_array;
984 sub init_customerprices {
988 my @customerprice_array = ();
989 my $customerprices = delete($::form->{customerprices}) || [];
991 foreach my $customerprice ( @{$customerprices} ) {
992 next unless $customerprice->{customer_id};
994 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
995 id => $customerprice->{id},
996 customer_partnumber => $customerprice->{customer_partnumber},
997 customer_id => $customerprice->{customer_id} || '',
998 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
999 sortorder => $position,
1000 ) or die "Can't create cu";
1001 # $cu->id($customerprice->{id}) if $customerprice->{id};
1002 push(@customerprice_array, $cu);
1004 return \@customerprice_array;
1007 sub init_assembly_items {
1011 my $assembly_items = delete($::form->{assembly_items}) || [];
1012 foreach my $assembly_item ( @{$assembly_items} ) {
1013 next unless $assembly_item->{parts_id};
1015 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1016 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1017 bom => $assembly_item->{bom},
1018 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1019 position => $position,
1026 sub init_all_warehouses {
1028 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1031 sub init_all_languages {
1032 SL::DB::Manager::Language->get_all_sorted;
1035 sub init_all_partsgroups {
1037 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1040 sub init_all_buchungsgruppen {
1042 if ( $self->part->orphaned ) {
1043 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1045 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1049 sub init_shops_not_assigned {
1052 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1053 if ( @used_shop_ids ) {
1054 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1057 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1061 sub init_all_units {
1063 if ( $self->part->orphaned ) {
1064 return SL::DB::Manager::Unit->get_all_sorted;
1066 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1070 sub init_all_payment_terms {
1072 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1075 sub init_all_price_factors {
1076 SL::DB::Manager::PriceFactor->get_all_sorted;
1079 sub init_all_pricegroups {
1080 SL::DB::Manager::Pricegroup->get_all_sorted;
1083 # model used to filter/display the parts in the multi-items dialog
1084 sub init_multi_items_models {
1085 SL::Controller::Helper::GetModels->new(
1086 controller => $_[0],
1088 with_objects => [ qw(unit_obj partsgroup classification) ],
1089 disable_plugin => 'paginated',
1090 source => $::form->{multi_items},
1096 partnumber => t8('Partnumber'),
1097 description => t8('Description')}
1101 sub init_parts_classification_filter {
1102 return [] unless $::form->{parts_classification_type};
1104 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1105 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1107 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1110 # simple checks to run on $::form before saving
1112 sub form_check_part_description_exists {
1115 return 1 if $::form->{part}{description};
1117 $self->js->flash('error', t8('Part Description missing!'))
1118 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1119 ->focus('#part_description');
1123 sub form_check_assortment_items_exist {
1126 return 1 unless $::form->{part}{part_type} eq 'assortment';
1127 # skip item check for existing assortments that have been used
1128 return 1 if ($self->part->id and !$self->part->orphaned);
1130 # new or orphaned parts must have items in $::form->{assortment_items}
1131 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1132 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1133 ->focus('#add_assortment_item_name')
1134 ->flash('error', t8('The assortment doesn\'t have any items.'));
1140 sub form_check_assortment_items_unique {
1143 return 1 unless $::form->{part}{part_type} eq 'assortment';
1145 my %duplicate_elements;
1147 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1148 $duplicate_elements{$_}++ if $count{$_}++;
1151 if ( keys %duplicate_elements ) {
1152 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1153 ->flash('error', t8('There are duplicate assortment items'));
1159 sub form_check_assembly_items_exist {
1162 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1164 # skip item check for existing assembly that have been used
1165 return 1 if ($self->part->id and !$self->part->orphaned);
1167 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1168 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1169 ->focus('#add_assembly_item_name')
1170 ->flash('error', t8('The assembly doesn\'t have any items.'));
1176 sub form_check_partnumber_is_unique {
1179 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1180 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1182 $self->js->flash('error', t8('The partnumber already exists!'))
1183 ->focus('#part_description');
1190 # general checking functions
1193 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1199 $self->form_check_part_description_exists || return 0;
1200 $self->form_check_assortment_items_exist || return 0;
1201 $self->form_check_assortment_items_unique || return 0;
1202 $self->form_check_assembly_items_exist || return 0;
1203 $self->form_check_partnumber_is_unique || return 0;
1208 sub check_has_valid_part_type {
1209 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1213 sub normalize_text_blocks {
1216 # check if feature is enabled (select normalize_part_descriptions from defaults)
1217 return unless ($::instance_conf->get_normalize_part_descriptions);
1220 foreach (qw(description)) {
1221 $self->part->{$_} =~ s/\s+$//s;
1222 $self->part->{$_} =~ s/^\s+//s;
1223 $self->part->{$_} =~ s/ {2,}/ /g;
1225 # html block (caveat: can be circumvented by using bold or italics)
1226 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1227 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1231 sub render_assortment_items_to_html {
1232 my ($self, $assortment_items, $number_of_items) = @_;
1234 my $position = $number_of_items + 1;
1236 foreach my $ai (@$assortment_items) {
1237 $html .= $self->p->render('part/_assortment_row',
1238 PART => $self->part,
1239 orphaned => $self->orphaned,
1241 listrow => $position % 2 ? 1 : 0,
1242 position => $position, # for legacy assemblies
1249 sub render_assembly_items_to_html {
1250 my ($self, $assembly_items, $number_of_items) = @_;
1252 my $position = $number_of_items + 1;
1254 foreach my $ai (@{$assembly_items}) {
1255 $html .= $self->p->render('part/_assembly_row',
1256 PART => $self->part,
1257 orphaned => $self->orphaned,
1259 listrow => $position % 2 ? 1 : 0,
1260 position => $position, # for legacy assemblies
1267 sub parse_add_items_to_objects {
1268 my ($self, %params) = @_;
1269 my $part_type = $params{part_type};
1270 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1271 my $position = $params{position} || 1;
1273 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1276 foreach my $item ( @add_items ) {
1277 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1279 if ( $part_type eq 'assortment' ) {
1280 $ai = SL::DB::AssortmentItem->new(part => $part,
1281 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1282 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1283 position => $position,
1284 ) or die "Can't create AssortmentItem from item";
1285 } elsif ( $part_type eq 'assembly' ) {
1286 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1287 # id => $self->assembly->id, # will be set on save
1288 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1289 bom => 0, # default when adding: no bom
1290 position => $position,
1293 die "part_type must be assortment or assembly";
1295 push(@item_objects, $ai);
1299 return \@item_objects;
1302 sub _setup_form_action_bar {
1305 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1306 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1308 for my $bar ($::request->layout->get('actionbar')) {
1313 call => [ 'kivi.Part.save' ],
1314 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1318 call => [ 'kivi.Part.use_as_new' ],
1319 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1320 : !$may_edit ? t8('You do not have the permissions to access this function.')
1323 ], # end of combobox "Save"
1327 call => [ 'kivi.Part.delete' ],
1328 confirm => t8('Do you really want to delete this object?'),
1329 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1330 : !$may_edit ? t8('You do not have the permissions to access this function.')
1331 : !$self->part->orphaned ? t8('This object has already been used.')
1332 : $used_in_pricerules ? t8('This object is used in price rules.')
1340 call => [ 'kivi.Part.open_history_popup' ],
1341 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1342 : !$may_edit ? t8('You do not have the permissions to access this function.')
1357 SL::Controller::Part - Part CRUD controller
1361 Controller for adding/editing/saving/deleting parts.
1363 All the relations are loaded at once and saving the part, adding a history
1364 entry and saving CVars happens inside one transaction. When saving the old
1365 relations are deleted and written as new to the database.
1367 Relations for parts:
1375 =item assembly items
1377 =item assortment items
1385 There are 4 different part types:
1391 The "default" part type.
1393 inventory_accno_id is set.
1397 Services can't be stocked.
1399 inventory_accno_id isn't set.
1403 Assemblies consist of other parts, services, assemblies or assortments. They
1404 aren't meant to be bought, only sold. To add assemblies to stock you typically
1405 have to make them, which reduces the stock by its respective components. Once
1406 an assembly item has been created there is currently no way to "disassemble" it
1407 again. An assembly item can appear several times in one assembly. An assmbly is
1408 sold as one item with a defined sellprice and lastcost. If the component prices
1409 change the assortment price remains the same. The assembly items may be printed
1410 in a record if the item's "bom" is set.
1414 Similar to assembly, but each assortment item may only appear once per
1415 assortment. When selling an assortment the assortment items are added to the
1416 record together with the assortment, which is added with sellprice 0.
1418 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1419 determined by the sum of the current assortment item prices when the assortment
1420 is added to a record. This also means that price rules and customer discounts
1421 will be applied to the assortment items.
1423 Once the assortment items have been added they may be modified or deleted, just
1424 as if they had been added manually, the individual assortment items aren't
1425 linked to the assortment or the other assortment items in any way.
1433 =item C<action_add_part>
1435 =item C<action_add_service>
1437 =item C<action_add_assembly>
1439 =item C<action_add_assortment>
1441 =item C<action_add PART_TYPE>
1443 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1444 parameter part_type as an action. Example:
1446 controller.pl?action=Part/add&part_type=service
1448 =item C<action_add_from_record>
1450 When adding new items to records they can be created on the fly if the entered
1451 partnumber or description doesn't exist yet. After being asked what part type
1452 the new item should have the user is redirected to the correct edit page.
1454 Depending on whether the item was added from a sales or a purchase record, only
1455 the relevant part classifications should be selectable for new item, so this
1456 parameter is passed on via a hidden parts_classification_type in the new_item
1459 =item C<action_save>
1461 Saves the current part and then reloads the edit page for the part.
1463 =item C<action_use_as_new>
1465 Takes the information from the current part, plus any modifications made on the
1466 page, and creates a new edit page that is ready to be saved. The partnumber is
1467 set empty, so a new partnumber from the number range will be used if the user
1468 doesn't enter one manually.
1470 Unsaved changes to the original part aren't updated.
1472 The part type cannot be changed in this way.
1474 =item C<action_delete>
1476 Deletes the current part and then redirects to the main page, there is no
1479 The delete button only appears if the part is 'orphaned', according to
1480 SL::DB::Part orphaned.
1482 The part can't be deleted if it appears in invoices, orders, delivery orders,
1483 the inventory, or is part of an assembly or assortment.
1485 If the part is deleted its relations prices, makdemodel, assembly,
1486 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1488 Before this controller items that appeared in inventory didn't count as
1489 orphaned and could be deleted and the inventory entries were also deleted, this
1490 "feature" hasn't been implemented.
1492 =item C<action_edit part.id>
1494 Load and display a part for editing.
1496 controller.pl?action=Part/edit&part.id=12345
1498 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1502 =head1 BUTTON ACTIONS
1508 Opens a popup displaying all the history entries. Once a new history controller
1509 is written the button could link there instead, with the part already selected.
1517 =item C<action_update_item_totals>
1519 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1520 amount of an item changes. The sum of all sellprices and lastcosts is
1521 calculated and the totals updated. Uses C<recalc_item_totals>.
1523 =item C<action_add_assortment_item>
1525 Adds a new assortment item from a part picker seleciton to the assortment item list
1527 If the item already exists in the assortment the item isn't added and a Flash
1530 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1531 after adding each new item, add the new object to the item objects that were
1532 already parsed, calculate totals via a dummy part then update the row and the
1535 =item C<action_add_assembly_item>
1537 Adds a new assembly item from a part picker seleciton to the assembly item list
1539 If the item already exists in the assembly a flash info is generated, but the
1542 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1543 after adding each new item, add the new object to the item objects that were
1544 already parsed, calculate totals via a dummy part then update the row and the
1547 =item C<action_add_multi_assortment_items>
1549 Parses the items to be added from the form generated by the multi input and
1550 appends the html of the tr-rows to the assortment item table. Afterwards all
1551 assortment items are renumbered and the sums recalculated via
1552 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1554 =item C<action_add_multi_assembly_items>
1556 Parses the items to be added from the form generated by the multi input and
1557 appends the html of the tr-rows to the assembly item table. Afterwards all
1558 assembly items are renumbered and the sums recalculated via
1559 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1561 =item C<action_show_multi_items_dialog>
1563 =item C<action_multi_items_update_result>
1565 =item C<action_add_makemodel_row>
1567 Add a new makemodel row with the vendor that was selected via the vendor
1570 Checks the already existing makemodels and warns if a row with that vendor
1571 already exists. Currently it is possible to have duplicate vendor rows.
1573 =item C<action_reorder_items>
1575 Sorts the item table for assembly or assortment items.
1577 =item C<action_warehouse_changed>
1581 =head1 ACTIONS part picker
1585 =item C<action_ajax_autocomplete>
1587 =item C<action_test_page>
1589 =item C<action_part_picker_search>
1591 =item C<action_part_picker_result>
1593 =item C<action_show>
1603 Calls some simple checks that test the submitted $::form for obvious errors.
1604 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1606 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1607 some cases extra actions are taken, e.g. if the part description is missing the
1608 basic data tab is selected and the description input field is focussed.
1614 =item C<form_check_part_description_exists>
1616 =item C<form_check_assortment_items_exist>
1618 =item C<form_check_assortment_items_unique>
1620 =item C<form_check_assembly_items_exist>
1622 =item C<form_check_partnumber_is_unique>
1626 =head1 HELPER FUNCTIONS
1632 When submitting the form for saving, parses the transmitted form. Expects the
1636 $::form->{makemodels}
1637 $::form->{translations}
1639 $::form->{assemblies}
1640 $::form->{assortments}
1642 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1644 =item C<recalc_item_totals %params>
1646 Helper function for calculating the total lastcost and sellprice for assemblies
1647 or assortments according to their items, which are parsed from the current
1650 Is called whenever the qty of an item is changed or items are deleted.
1654 * part_type : 'assortment' or 'assembly' (mandatory)
1656 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1658 Depending on the price_type the lastcost sum or sellprice sum is returned.
1660 Doesn't work for recursive items.
1664 =head1 GET SET INITS
1666 There are get_set_inits for
1674 which parse $::form and automatically create an array of objects.
1676 These inits are used during saving and each time a new element is added.
1680 =item C<init_makemodels>
1682 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1683 $self->part->makemodels, ready to be saved.
1685 Used for saving parts and adding new makemodel rows.
1687 =item C<parse_add_items_to_objects PART_TYPE>
1689 Parses the resulting form from either the part-picker submit or the multi-item
1690 submit, and creates an arrayref of assortment_item or assembly objects, that
1691 can be rendered via C<render_assortment_items_to_html> or
1692 C<render_assembly_items_to_html>.
1694 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1695 Optional param: position (used for numbering and listrow class)
1697 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1699 Takes an array_ref of assortment_items, and generates tables rows ready for
1700 adding to the assortment table. Is used when a part is loaded, or whenever new
1701 assortment items are added.
1703 =item C<parse_form_makemodels>
1705 Makemodels can't just be overwritten, because of the field "lastupdate", that
1706 remembers when the lastcost for that vendor changed the last time.
1708 So the original values are cloned and remembered, so we can compare if lastcost
1709 was changed in $::form, and keep or update lastupdate.
1711 lastcost isn't updated until the first time it was saved with a value, until
1714 Also a boolean "makemodel" needs to be written in parts, depending on whether
1715 makemodel entries exist or not.
1717 We still need init_makemodels for when we open the part for editing.
1727 It should be possible to jump to the edit page in a specific tab
1731 Support callbacks, e.g. creating a new part from within an order, and jumping
1732 back to the order again afterwards.
1736 Support units when adding assembly items or assortment items. Currently the
1737 default unit of the item is always used.
1741 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1742 consists of other assemblies.
1748 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>