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 {
402 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
403 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
407 sub action_multi_items_update_result {
410 $::form->{multi_items}->{filter}->{obsolete} = 0;
412 my $count = $_[0]->multi_items_models->count;
415 my $text = escape($::locale->text('No results.'));
416 $_[0]->render($text, { layout => 0 });
417 } elsif ($count > $max_count) {
418 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
419 $_[0]->render($text, { layout => 0 });
421 my $multi_items = $_[0]->multi_items_models->get;
422 $_[0]->render('part/_multi_items_result', { layout => 0 },
423 multi_items => $multi_items);
427 sub action_add_makemodel_row {
430 my $vendor_id = $::form->{add_makemodel};
432 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
433 return $self->js->error(t8("No vendor selected or found!"))->render;
435 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
436 $self->js->flash('info', t8("This vendor has already been added."));
439 my $position = scalar @{$self->makemodels} + 1;
441 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
445 sortorder => $position,
446 ) or die "Can't create MakeModel object";
448 my $row_as_html = $self->p->render('part/_makemodel_row',
450 listrow => $position % 2 ? 0 : 1,
453 # after selection focus on the model field in the row that was just added
455 ->append('#makemodel_rows', $row_as_html) # append in tbody
456 ->val('.add_makemodel_input', '')
457 ->run('kivi.Part.focus_last_makemodel_input')
461 sub action_add_customerprice_row {
464 my $customer_id = $::form->{add_customerprice};
466 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
467 or return $self->js->error(t8("No customer selected or found!"))->render;
469 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
470 $self->js->flash('info', t8("This customer has already been added."));
473 my $position = scalar @{ $self->customerprices } + 1;
475 my $cu = SL::DB::PartCustomerPrice->new(
476 customer_id => $customer->id,
477 customer_partnumber => '',
479 sortorder => $position,
480 ) or die "Can't create Customerprice object";
482 my $row_as_html = $self->p->render(
483 'part/_customerprice_row',
484 customerprice => $cu,
485 listrow => $position % 2 ? 0
489 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
490 ->val('.add_customerprice_input', '')
491 ->run('kivi.Part.focus_last_customerprice_input')->render;
494 sub action_reorder_items {
497 my $part_type = $::form->{part_type};
500 partnumber => sub { $_[0]->part->partnumber },
501 description => sub { $_[0]->part->description },
502 qty => sub { $_[0]->qty },
503 sellprice => sub { $_[0]->part->sellprice },
504 lastcost => sub { $_[0]->part->lastcost },
505 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
508 my $method = $sort_keys{$::form->{order_by}};
511 if ($part_type eq 'assortment') {
512 @items = @{ $self->assortment_items };
514 @items = @{ $self->assembly_items };
517 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
518 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
519 if ($::form->{sort_dir}) {
520 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
522 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
525 if ($::form->{sort_dir}) {
526 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
528 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
532 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
535 sub action_warehouse_changed {
538 if ($::form->{warehouse_id} ) {
539 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
540 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
542 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
543 $self->bin($self->warehouse->bins->[0]);
545 ->html('#bin', $self->build_bin_select)
546 ->focus('#part_bin_id');
547 return $self->js->render;
551 # no warehouse was selected, empty the bin field and reset the id
553 ->val('#part_bin_id', undef)
556 return $self->js->render;
559 sub action_ajax_autocomplete {
560 my ($self, %params) = @_;
562 # if someone types something, and hits enter, assume he entered the full name.
563 # if something matches, treat that as sole match
564 # since we need a second get models instance with different filters for that,
565 # we only modify the original filter temporarily in place
566 if ($::form->{prefer_exact}) {
567 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
569 my $exact_models = SL::Controller::Helper::GetModels->new(
572 paginated => { per_page => 2 },
573 with_objects => [ qw(unit_obj classification) ],
576 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
577 $self->parts($exact_matches);
583 value => $_->displayable_name,
584 label => $_->displayable_name,
586 partnumber => $_->partnumber,
587 description => $_->description,
589 part_type => $_->part_type,
591 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
593 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
595 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
598 sub action_test_page {
599 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
602 sub action_part_picker_search {
603 $_[0]->render('part/part_picker_search', { layout => 0 });
606 sub action_part_picker_result {
607 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
613 if ($::request->type eq 'json') {
618 $part_hash = $self->part->as_tree;
619 $part_hash->{cvars} = $self->part->cvar_as_hashref;
622 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
627 sub validate_add_items {
628 scalar @{$::form->{add_items}};
631 sub prepare_assortment_render_vars {
634 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
635 items_lastcost_sum => $self->part->items_lastcost_sum,
636 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
638 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
643 sub prepare_assembly_render_vars {
646 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
648 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
649 items_lastcost_sum => $self->part->items_lastcost_sum,
650 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
652 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
660 check_has_valid_part_type($self->part->part_type);
662 $self->_set_javascript;
663 $self->_setup_form_action_bar;
665 my %title_hash = ( part => t8('Add Part'),
666 assembly => t8('Add Assembly'),
667 service => t8('Add Service'),
668 assortment => t8('Add Assortment'),
673 title => $title_hash{$self->part->part_type},
678 sub _set_javascript {
680 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
681 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
684 sub recalc_item_totals {
685 my ($self, %params) = @_;
687 if ( $params{part_type} eq 'assortment' ) {
688 return 0 unless scalar @{$self->assortment_items};
689 } elsif ( $params{part_type} eq 'assembly' ) {
690 return 0 unless scalar @{$self->assembly_items};
692 carp "can only calculate sum for assortments and assemblies";
695 my $part = SL::DB::Part->new(part_type => $params{part_type});
696 if ( $part->is_assortment ) {
697 $part->assortment_items( @{$self->assortment_items} );
698 if ( $params{price_type} eq 'lastcost' ) {
699 return $part->items_lastcost_sum;
701 if ( $params{pricegroup_id} ) {
702 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
704 return $part->items_sellprice_sum;
707 } elsif ( $part->is_assembly ) {
708 $part->assemblies( @{$self->assembly_items} );
709 if ( $params{price_type} eq 'lastcost' ) {
710 return $part->items_lastcost_sum;
712 return $part->items_sellprice_sum;
717 sub check_part_not_modified {
720 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
727 my $is_new = !$self->part->id;
729 my $params = delete($::form->{part}) || { };
731 delete $params->{id};
732 $self->part->assign_attributes(%{ $params});
733 $self->part->bin_id(undef) unless $self->part->warehouse_id;
735 $self->normalize_text_blocks;
737 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
738 # will be the case for used assortments when saving, or when a used assortment
740 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
741 $self->part->assortment_items([]);
742 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
745 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
746 $self->part->assemblies([]); # completely rewrite assortments each time
747 $self->part->add_assemblies( @{ $self->assembly_items } );
750 $self->part->translations([]);
751 $self->parse_form_translations;
753 $self->part->prices([]);
754 $self->parse_form_prices;
756 $self->parse_form_customerprices;
757 $self->parse_form_makemodels;
760 sub parse_form_prices {
762 # only save prices > 0
763 my $prices = delete($::form->{prices}) || [];
764 foreach my $price ( @{$prices} ) {
765 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
766 next unless $sellprice > 0; # skip negative prices as well
767 my $p = SL::DB::Price->new(parts_id => $self->part->id,
768 pricegroup_id => $price->{pricegroup_id},
771 $self->part->add_prices($p);
775 sub parse_form_translations {
777 # don't add empty translations
778 my $translations = delete($::form->{translations}) || [];
779 foreach my $translation ( @{$translations} ) {
780 next unless $translation->{translation};
781 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
782 $self->part->add_translations( $translation );
786 sub parse_form_makemodels {
790 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
791 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
794 $self->part->makemodels([]);
797 my $makemodels = delete($::form->{makemodels}) || [];
798 foreach my $makemodel ( @{$makemodels} ) {
799 next unless $makemodel->{make};
801 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
803 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
804 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
806 make => $makemodel->{make},
807 model => $makemodel->{model} || '',
808 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
809 sortorder => $position,
811 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
812 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
813 # don't change lastupdate
814 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
815 # new makemodel, no lastcost entered, leave lastupdate empty
816 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
817 # lastcost hasn't changed, use original lastupdate
818 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
820 $mm->lastupdate(DateTime->now);
822 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
823 $self->part->add_makemodels($mm);
827 sub parse_form_customerprices {
830 my $customerprices_map;
831 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
832 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
835 $self->part->customerprices([]);
838 my $customerprices = delete($::form->{customerprices}) || [];
839 foreach my $customerprice ( @{$customerprices} ) {
840 next unless $customerprice->{customer_id};
842 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
844 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
845 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
847 customer_id => $customerprice->{customer_id},
848 customer_partnumber => $customerprice->{customer_partnumber} || '',
849 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
850 sortorder => $position,
852 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
853 # lastupdate isn't set, original price is 0 and new lastcost is 0
854 # don't change lastupdate
855 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
856 # new customerprice, no lastcost entered, leave lastupdate empty
857 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
858 # price hasn't changed, use original lastupdate
859 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
861 $cu->lastupdate(DateTime->now);
863 $self->part->add_customerprices($cu);
867 sub build_bin_select {
868 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
869 title_key => 'description',
870 default => $_[0]->bin->id,
875 # get_set_inits for partpicker
878 if ($::form->{no_paginate}) {
879 $_[0]->models->disable_plugin('paginated');
885 # get_set_inits for part controller
889 # used by edit, save, delete and add
891 if ( $::form->{part}{id} ) {
892 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
894 die "part_type missing" unless $::form->{part}{part_type};
895 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
901 return $self->part->orphaned;
907 SL::Controller::Helper::GetModels->new(
914 partnumber => t8('Partnumber'),
915 description => t8('Description'),
917 with_objects => [ qw(unit_obj classification) ],
926 sub init_assortment_items {
927 # this init is used while saving and whenever assortments change dynamically
931 my $assortment_items = delete($::form->{assortment_items}) || [];
932 foreach my $assortment_item ( @{$assortment_items} ) {
933 next unless $assortment_item->{parts_id};
935 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
936 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
937 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
938 charge => $assortment_item->{charge},
939 unit => $assortment_item->{unit} || $part->unit,
940 position => $position,
948 sub init_makemodels {
952 my @makemodel_array = ();
953 my $makemodels = delete($::form->{makemodels}) || [];
955 foreach my $makemodel ( @{$makemodels} ) {
956 next unless $makemodel->{make};
958 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
959 id => $makemodel->{id},
960 make => $makemodel->{make},
961 model => $makemodel->{model} || '',
962 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
963 sortorder => $position,
964 ) or die "Can't create mm";
965 # $mm->id($makemodel->{id}) if $makemodel->{id};
966 push(@makemodel_array, $mm);
968 return \@makemodel_array;
971 sub init_customerprices {
975 my @customerprice_array = ();
976 my $customerprices = delete($::form->{customerprices}) || [];
978 foreach my $customerprice ( @{$customerprices} ) {
979 next unless $customerprice->{customer_id};
981 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
982 id => $customerprice->{id},
983 customer_partnumber => $customerprice->{customer_partnumber},
984 customer_id => $customerprice->{customer_id} || '',
985 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
986 sortorder => $position,
987 ) or die "Can't create cu";
988 # $cu->id($customerprice->{id}) if $customerprice->{id};
989 push(@customerprice_array, $cu);
991 return \@customerprice_array;
994 sub init_assembly_items {
998 my $assembly_items = delete($::form->{assembly_items}) || [];
999 foreach my $assembly_item ( @{$assembly_items} ) {
1000 next unless $assembly_item->{parts_id};
1002 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1003 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1004 bom => $assembly_item->{bom},
1005 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1006 position => $position,
1013 sub init_all_warehouses {
1015 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1018 sub init_all_languages {
1019 SL::DB::Manager::Language->get_all_sorted;
1022 sub init_all_partsgroups {
1024 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1027 sub init_all_buchungsgruppen {
1029 if ( $self->part->orphaned ) {
1030 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1032 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1036 sub init_shops_not_assigned {
1039 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1040 if ( @used_shop_ids ) {
1041 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1044 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1048 sub init_all_units {
1050 if ( $self->part->orphaned ) {
1051 return SL::DB::Manager::Unit->get_all_sorted;
1053 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1057 sub init_all_payment_terms {
1059 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1062 sub init_all_price_factors {
1063 SL::DB::Manager::PriceFactor->get_all_sorted;
1066 sub init_all_pricegroups {
1067 SL::DB::Manager::Pricegroup->get_all_sorted;
1070 # model used to filter/display the parts in the multi-items dialog
1071 sub init_multi_items_models {
1072 SL::Controller::Helper::GetModels->new(
1073 controller => $_[0],
1075 with_objects => [ qw(unit_obj partsgroup classification) ],
1076 disable_plugin => 'paginated',
1077 source => $::form->{multi_items},
1083 partnumber => t8('Partnumber'),
1084 description => t8('Description')}
1088 sub init_parts_classification_filter {
1089 return [] unless $::form->{parts_classification_type};
1091 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1092 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1094 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1097 # simple checks to run on $::form before saving
1099 sub form_check_part_description_exists {
1102 return 1 if $::form->{part}{description};
1104 $self->js->flash('error', t8('Part Description missing!'))
1105 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1106 ->focus('#part_description');
1110 sub form_check_assortment_items_exist {
1113 return 1 unless $::form->{part}{part_type} eq 'assortment';
1114 # skip item check for existing assortments that have been used
1115 return 1 if ($self->part->id and !$self->part->orphaned);
1117 # new or orphaned parts must have items in $::form->{assortment_items}
1118 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1119 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1120 ->focus('#add_assortment_item_name')
1121 ->flash('error', t8('The assortment doesn\'t have any items.'));
1127 sub form_check_assortment_items_unique {
1130 return 1 unless $::form->{part}{part_type} eq 'assortment';
1132 my %duplicate_elements;
1134 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1135 $duplicate_elements{$_}++ if $count{$_}++;
1138 if ( keys %duplicate_elements ) {
1139 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1140 ->flash('error', t8('There are duplicate assortment items'));
1146 sub form_check_assembly_items_exist {
1149 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1151 # skip item check for existing assembly that have been used
1152 return 1 if ($self->part->id and !$self->part->orphaned);
1154 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1155 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1156 ->focus('#add_assembly_item_name')
1157 ->flash('error', t8('The assembly doesn\'t have any items.'));
1163 sub form_check_partnumber_is_unique {
1166 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1167 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1169 $self->js->flash('error', t8('The partnumber already exists!'))
1170 ->focus('#part_description');
1177 # general checking functions
1180 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1186 $self->form_check_part_description_exists || return 0;
1187 $self->form_check_assortment_items_exist || return 0;
1188 $self->form_check_assortment_items_unique || return 0;
1189 $self->form_check_assembly_items_exist || return 0;
1190 $self->form_check_partnumber_is_unique || return 0;
1195 sub check_has_valid_part_type {
1196 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1200 sub normalize_text_blocks {
1203 # check if feature is enabled (select normalize_part_descriptions from defaults)
1204 return unless ($::instance_conf->get_normalize_part_descriptions);
1207 foreach (qw(description)) {
1208 $self->part->{$_} =~ s/\s+$//s;
1209 $self->part->{$_} =~ s/^\s+//s;
1210 $self->part->{$_} =~ s/ {2,}/ /g;
1212 # html block (caveat: can be circumvented by using bold or italics)
1213 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1214 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1218 sub render_assortment_items_to_html {
1219 my ($self, $assortment_items, $number_of_items) = @_;
1221 my $position = $number_of_items + 1;
1223 foreach my $ai (@$assortment_items) {
1224 $html .= $self->p->render('part/_assortment_row',
1225 PART => $self->part,
1226 orphaned => $self->orphaned,
1228 listrow => $position % 2 ? 1 : 0,
1229 position => $position, # for legacy assemblies
1236 sub render_assembly_items_to_html {
1237 my ($self, $assembly_items, $number_of_items) = @_;
1239 my $position = $number_of_items + 1;
1241 foreach my $ai (@{$assembly_items}) {
1242 $html .= $self->p->render('part/_assembly_row',
1243 PART => $self->part,
1244 orphaned => $self->orphaned,
1246 listrow => $position % 2 ? 1 : 0,
1247 position => $position, # for legacy assemblies
1254 sub parse_add_items_to_objects {
1255 my ($self, %params) = @_;
1256 my $part_type = $params{part_type};
1257 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1258 my $position = $params{position} || 1;
1260 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1263 foreach my $item ( @add_items ) {
1264 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1266 if ( $part_type eq 'assortment' ) {
1267 $ai = SL::DB::AssortmentItem->new(part => $part,
1268 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1269 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1270 position => $position,
1271 ) or die "Can't create AssortmentItem from item";
1272 } elsif ( $part_type eq 'assembly' ) {
1273 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1274 # id => $self->assembly->id, # will be set on save
1275 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1276 bom => 0, # default when adding: no bom
1277 position => $position,
1280 die "part_type must be assortment or assembly";
1282 push(@item_objects, $ai);
1286 return \@item_objects;
1289 sub _setup_form_action_bar {
1292 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1293 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1295 for my $bar ($::request->layout->get('actionbar')) {
1300 call => [ 'kivi.Part.save' ],
1301 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1305 call => [ 'kivi.Part.use_as_new' ],
1306 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1307 : !$may_edit ? t8('You do not have the permissions to access this function.')
1310 ], # end of combobox "Save"
1314 call => [ 'kivi.Part.delete' ],
1315 confirm => t8('Do you really want to delete this object?'),
1316 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1317 : !$may_edit ? t8('You do not have the permissions to access this function.')
1318 : !$self->part->orphaned ? t8('This object has already been used.')
1319 : $used_in_pricerules ? t8('This object is used in price rules.')
1327 call => [ 'kivi.Part.open_history_popup' ],
1328 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1329 : !$may_edit ? t8('You do not have the permissions to access this function.')
1344 SL::Controller::Part - Part CRUD controller
1348 Controller for adding/editing/saving/deleting parts.
1350 All the relations are loaded at once and saving the part, adding a history
1351 entry and saving CVars happens inside one transaction. When saving the old
1352 relations are deleted and written as new to the database.
1354 Relations for parts:
1362 =item assembly items
1364 =item assortment items
1372 There are 4 different part types:
1378 The "default" part type.
1380 inventory_accno_id is set.
1384 Services can't be stocked.
1386 inventory_accno_id isn't set.
1390 Assemblies consist of other parts, services, assemblies or assortments. They
1391 aren't meant to be bought, only sold. To add assemblies to stock you typically
1392 have to make them, which reduces the stock by its respective components. Once
1393 an assembly item has been created there is currently no way to "disassemble" it
1394 again. An assembly item can appear several times in one assembly. An assmbly is
1395 sold as one item with a defined sellprice and lastcost. If the component prices
1396 change the assortment price remains the same. The assembly items may be printed
1397 in a record if the item's "bom" is set.
1401 Similar to assembly, but each assortment item may only appear once per
1402 assortment. When selling an assortment the assortment items are added to the
1403 record together with the assortment, which is added with sellprice 0.
1405 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1406 determined by the sum of the current assortment item prices when the assortment
1407 is added to a record. This also means that price rules and customer discounts
1408 will be applied to the assortment items.
1410 Once the assortment items have been added they may be modified or deleted, just
1411 as if they had been added manually, the individual assortment items aren't
1412 linked to the assortment or the other assortment items in any way.
1420 =item C<action_add_part>
1422 =item C<action_add_service>
1424 =item C<action_add_assembly>
1426 =item C<action_add_assortment>
1428 =item C<action_add PART_TYPE>
1430 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1431 parameter part_type as an action. Example:
1433 controller.pl?action=Part/add&part_type=service
1435 =item C<action_add_from_record>
1437 When adding new items to records they can be created on the fly if the entered
1438 partnumber or description doesn't exist yet. After being asked what part type
1439 the new item should have the user is redirected to the correct edit page.
1441 Depending on whether the item was added from a sales or a purchase record, only
1442 the relevant part classifications should be selectable for new item, so this
1443 parameter is passed on via a hidden parts_classification_type in the new_item
1446 =item C<action_save>
1448 Saves the current part and then reloads the edit page for the part.
1450 =item C<action_use_as_new>
1452 Takes the information from the current part, plus any modifications made on the
1453 page, and creates a new edit page that is ready to be saved. The partnumber is
1454 set empty, so a new partnumber from the number range will be used if the user
1455 doesn't enter one manually.
1457 Unsaved changes to the original part aren't updated.
1459 The part type cannot be changed in this way.
1461 =item C<action_delete>
1463 Deletes the current part and then redirects to the main page, there is no
1466 The delete button only appears if the part is 'orphaned', according to
1467 SL::DB::Part orphaned.
1469 The part can't be deleted if it appears in invoices, orders, delivery orders,
1470 the inventory, or is part of an assembly or assortment.
1472 If the part is deleted its relations prices, makdemodel, assembly,
1473 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1475 Before this controller items that appeared in inventory didn't count as
1476 orphaned and could be deleted and the inventory entries were also deleted, this
1477 "feature" hasn't been implemented.
1479 =item C<action_edit part.id>
1481 Load and display a part for editing.
1483 controller.pl?action=Part/edit&part.id=12345
1485 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1489 =head1 BUTTON ACTIONS
1495 Opens a popup displaying all the history entries. Once a new history controller
1496 is written the button could link there instead, with the part already selected.
1504 =item C<action_update_item_totals>
1506 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1507 amount of an item changes. The sum of all sellprices and lastcosts is
1508 calculated and the totals updated. Uses C<recalc_item_totals>.
1510 =item C<action_add_assortment_item>
1512 Adds a new assortment item from a part picker seleciton to the assortment item list
1514 If the item already exists in the assortment the item isn't added and a Flash
1517 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1518 after adding each new item, add the new object to the item objects that were
1519 already parsed, calculate totals via a dummy part then update the row and the
1522 =item C<action_add_assembly_item>
1524 Adds a new assembly item from a part picker seleciton to the assembly item list
1526 If the item already exists in the assembly a flash info is generated, but the
1529 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1530 after adding each new item, add the new object to the item objects that were
1531 already parsed, calculate totals via a dummy part then update the row and the
1534 =item C<action_add_multi_assortment_items>
1536 Parses the items to be added from the form generated by the multi input and
1537 appends the html of the tr-rows to the assortment item table. Afterwards all
1538 assortment items are renumbered and the sums recalculated via
1539 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1541 =item C<action_add_multi_assembly_items>
1543 Parses the items to be added from the form generated by the multi input and
1544 appends the html of the tr-rows to the assembly item table. Afterwards all
1545 assembly items are renumbered and the sums recalculated via
1546 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1548 =item C<action_show_multi_items_dialog>
1550 =item C<action_multi_items_update_result>
1552 =item C<action_add_makemodel_row>
1554 Add a new makemodel row with the vendor that was selected via the vendor
1557 Checks the already existing makemodels and warns if a row with that vendor
1558 already exists. Currently it is possible to have duplicate vendor rows.
1560 =item C<action_reorder_items>
1562 Sorts the item table for assembly or assortment items.
1564 =item C<action_warehouse_changed>
1568 =head1 ACTIONS part picker
1572 =item C<action_ajax_autocomplete>
1574 =item C<action_test_page>
1576 =item C<action_part_picker_search>
1578 =item C<action_part_picker_result>
1580 =item C<action_show>
1590 Calls some simple checks that test the submitted $::form for obvious errors.
1591 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1593 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1594 some cases extra actions are taken, e.g. if the part description is missing the
1595 basic data tab is selected and the description input field is focussed.
1601 =item C<form_check_part_description_exists>
1603 =item C<form_check_assortment_items_exist>
1605 =item C<form_check_assortment_items_unique>
1607 =item C<form_check_assembly_items_exist>
1609 =item C<form_check_partnumber_is_unique>
1613 =head1 HELPER FUNCTIONS
1619 When submitting the form for saving, parses the transmitted form. Expects the
1623 $::form->{makemodels}
1624 $::form->{translations}
1626 $::form->{assemblies}
1627 $::form->{assortments}
1629 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1631 =item C<recalc_item_totals %params>
1633 Helper function for calculating the total lastcost and sellprice for assemblies
1634 or assortments according to their items, which are parsed from the current
1637 Is called whenever the qty of an item is changed or items are deleted.
1641 * part_type : 'assortment' or 'assembly' (mandatory)
1643 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1645 Depending on the price_type the lastcost sum or sellprice sum is returned.
1647 Doesn't work for recursive items.
1651 =head1 GET SET INITS
1653 There are get_set_inits for
1661 which parse $::form and automatically create an array of objects.
1663 These inits are used during saving and each time a new element is added.
1667 =item C<init_makemodels>
1669 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1670 $self->part->makemodels, ready to be saved.
1672 Used for saving parts and adding new makemodel rows.
1674 =item C<parse_add_items_to_objects PART_TYPE>
1676 Parses the resulting form from either the part-picker submit or the multi-item
1677 submit, and creates an arrayref of assortment_item or assembly objects, that
1678 can be rendered via C<render_assortment_items_to_html> or
1679 C<render_assembly_items_to_html>.
1681 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1682 Optional param: position (used for numbering and listrow class)
1684 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1686 Takes an array_ref of assortment_items, and generates tables rows ready for
1687 adding to the assortment table. Is used when a part is loaded, or whenever new
1688 assortment items are added.
1690 =item C<parse_form_makemodels>
1692 Makemodels can't just be overwritten, because of the field "lastupdate", that
1693 remembers when the lastcost for that vendor changed the last time.
1695 So the original values are cloned and remembered, so we can compare if lastcost
1696 was changed in $::form, and keep or update lastupdate.
1698 lastcost isn't updated until the first time it was saved with a value, until
1701 Also a boolean "makemodel" needs to be written in parts, depending on whether
1702 makemodel entries exist or not.
1704 We still need init_makemodels for when we open the part for editing.
1714 It should be possible to jump to the edit page in a specific tab
1718 Support callbacks, e.g. creating a new part from within an order, and jumping
1719 back to the order again afterwards.
1723 Support units when adding assembly items or assortment items. Currently the
1724 default unit of the item is always used.
1728 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1729 consists of other assemblies.
1735 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>