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 __PACKAGE__->run_before('normalize_text_blocks');
47 # actions for editing parts
50 my ($self, %params) = @_;
52 $self->part( SL::DB::Part->new_part );
56 sub action_add_service {
57 my ($self, %params) = @_;
59 $self->part( SL::DB::Part->new_service );
63 sub action_add_assembly {
64 my ($self, %params) = @_;
66 $self->part( SL::DB::Part->new_assembly );
70 sub action_add_assortment {
71 my ($self, %params) = @_;
73 $self->part( SL::DB::Part->new_assortment );
77 sub action_add_from_record {
80 check_has_valid_part_type($::form->{part}{part_type});
82 die 'parts_classification_type must be "sales" or "purchases"'
83 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
92 check_has_valid_part_type($::form->{part_type});
94 $self->action_add_part if $::form->{part_type} eq 'part';
95 $self->action_add_service if $::form->{part_type} eq 'service';
96 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
97 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
101 my ($self, %params) = @_;
103 # checks that depend only on submitted $::form
104 $self->check_form or return $self->js->render;
106 my $is_new = !$self->part->id; # $ part gets loaded here
108 # check that the part hasn't been modified
110 $self->check_part_not_modified or
111 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;
115 && $::form->{part}{partnumber}
116 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
118 return $self->js->error(t8('The partnumber is already being used'))->render;
123 my @errors = $self->part->validate;
124 return $self->js->error(@errors)->render if @errors;
126 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
127 $self->part->db->with_transaction(sub {
129 if ( $params{save_as_new} ) {
130 $self->part( $self->part->clone_and_reset_deep );
131 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
134 $self->part->save(cascade => 1);
136 SL::DB::History->new(
137 trans_id => $self->part->id,
138 snumbers => 'partnumber_' . $self->part->partnumber,
139 employee_id => SL::DB::Manager::Employee->current->id,
144 CVar->save_custom_variables(
145 dbh => $self->part->db->dbh,
147 trans_id => $self->part->id,
148 variables => $::form, # $::form->{cvar} would be nicer
153 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
156 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
158 if ( $::form->{callback} ) {
159 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
162 # default behaviour after save: reload item, this also resets last_modification!
163 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
167 sub action_save_as_new {
169 $self->action_save(save_as_new=>1);
175 my $db = $self->part->db; # $self->part has a get_set_init on $::form
177 my $partnumber = $self->part->partnumber; # remember for history log
182 # delete part, together with relationships that don't already
183 # have an ON DELETE CASCADE, e.g. makemodel and translation.
184 $self->part->delete(cascade => 1);
186 SL::DB::History->new(
187 trans_id => $self->part->id,
188 snumbers => 'partnumber_' . $partnumber,
189 employee_id => SL::DB::Manager::Employee->current->id,
191 addition => 'DELETED',
194 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
196 flash_later('info', t8('The item has been deleted.'));
197 if ( $::form->{callback} ) {
198 $self->redirect_to($::form->unescape($::form->{callback}));
200 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
204 sub action_use_as_new {
205 my ($self, %params) = @_;
207 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
208 $::form->{oldpartnumber} = $oldpart->partnumber;
210 $self->part($oldpart->clone_and_reset_deep);
212 $self->part->partnumber(undef);
218 my ($self, %params) = @_;
224 my ($self, %params) = @_;
226 $self->_set_javascript;
227 $self->_setup_form_action_bar;
229 my (%assortment_vars, %assembly_vars);
230 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
231 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
233 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
234 $_->{valid} = 1 for @{ $params{CUSTOM_VARIABLES} };
236 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
237 if (scalar @{ $params{CUSTOM_VARIABLES} });
239 my %title_hash = ( part => t8('Edit Part'),
240 assembly => t8('Edit Assembly'),
241 service => t8('Edit Service'),
242 assortment => t8('Edit Assortment'),
245 $self->part->prices([]) unless $self->part->prices;
246 $self->part->translations([]) unless $self->part->translations;
250 title => $title_hash{$self->part->part_type},
253 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
254 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
255 oldpartnumber => $::form->{oldpartnumber},
256 old_id => $::form->{old_id},
264 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
265 $_[0]->render('part/history', { layout => 0 },
266 history_entries => $history_entries);
269 sub action_update_item_totals {
272 my $part_type = $::form->{part_type};
273 die unless $part_type =~ /^(assortment|assembly)$/;
275 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
276 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
278 my $sum_diff = $sellprice_sum-$lastcost_sum;
281 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
282 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
283 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
284 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
285 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
286 ->no_flash_clear->render();
289 sub action_add_multi_assortment_items {
292 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
293 my $html = $self->render_assortment_items_to_html($item_objects);
295 $self->js->run('kivi.Part.close_picker_dialogs')
296 ->append('#assortment_rows', $html)
297 ->run('kivi.Part.renumber_positions')
298 ->run('kivi.Part.assortment_recalc')
302 sub action_add_multi_assembly_items {
305 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
307 foreach my $item (@{$item_objects}) {
308 my $errstr = validate_assembly($item->part,$self->part);
309 $self->js->flash('error',$errstr) if $errstr;
310 push (@checked_objects,$item) unless $errstr;
313 my $html = $self->render_assembly_items_to_html(\@checked_objects);
315 $self->js->run('kivi.Part.close_picker_dialogs')
316 ->append('#assembly_rows', $html)
317 ->run('kivi.Part.renumber_positions')
318 ->run('kivi.Part.assembly_recalc')
322 sub action_add_assortment_item {
323 my ($self, %params) = @_;
325 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
327 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
329 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
330 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
331 return $self->js->flash('error', t8("This part has already been added."))->render;
334 my $number_of_items = scalar @{$self->assortment_items};
335 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
336 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
338 push(@{$self->assortment_items}, @{$item_objects});
339 my $part = SL::DB::Part->new(part_type => 'assortment');
340 $part->assortment_items(@{$self->assortment_items});
341 my $items_sellprice_sum = $part->items_sellprice_sum;
342 my $items_lastcost_sum = $part->items_lastcost_sum;
343 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
346 ->append('#assortment_rows' , $html) # append in tbody
347 ->val('.add_assortment_item_input' , '')
348 ->run('kivi.Part.focus_last_assortment_input')
349 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
350 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
351 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
352 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
353 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
357 sub action_add_assembly_item {
360 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
362 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
364 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
366 my $duplicate_warning = 0; # duplicates are allowed, just warn
367 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
368 $duplicate_warning++;
371 my $number_of_items = scalar @{$self->assembly_items};
372 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
374 foreach my $item (@{$item_objects}) {
375 my $errstr = validate_assembly($item->part,$self->part);
376 return $self->js->flash('error',$errstr)->render if $errstr;
381 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
383 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
385 push(@{$self->assembly_items}, @{$item_objects});
386 my $part = SL::DB::Part->new(part_type => 'assembly');
387 $part->assemblies(@{$self->assembly_items});
388 my $items_sellprice_sum = $part->items_sellprice_sum;
389 my $items_lastcost_sum = $part->items_lastcost_sum;
390 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
393 ->append('#assembly_rows', $html) # append in tbody
394 ->val('.add_assembly_item_input' , '')
395 ->run('kivi.Part.focus_last_assembly_input')
396 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
397 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
398 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
399 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
400 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
404 sub action_show_multi_items_dialog {
405 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
406 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
410 sub action_multi_items_update_result {
413 $::form->{multi_items}->{filter}->{obsolete} = 0;
415 my $count = $_[0]->multi_items_models->count;
418 my $text = escape($::locale->text('No results.'));
419 $_[0]->render($text, { layout => 0 });
420 } elsif ($count > $max_count) {
421 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
422 $_[0]->render($text, { layout => 0 });
424 my $multi_items = $_[0]->multi_items_models->get;
425 $_[0]->render('part/_multi_items_result', { layout => 0 },
426 multi_items => $multi_items);
430 sub action_add_makemodel_row {
433 my $vendor_id = $::form->{add_makemodel};
435 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
436 return $self->js->error(t8("No vendor selected or found!"))->render;
438 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
439 $self->js->flash('info', t8("This vendor has already been added."));
442 my $position = scalar @{$self->makemodels} + 1;
444 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
448 sortorder => $position,
449 ) or die "Can't create MakeModel object";
451 my $row_as_html = $self->p->render('part/_makemodel_row',
453 listrow => $position % 2 ? 0 : 1,
456 # after selection focus on the model field in the row that was just added
458 ->append('#makemodel_rows', $row_as_html) # append in tbody
459 ->val('.add_makemodel_input', '')
460 ->run('kivi.Part.focus_last_makemodel_input')
464 sub action_add_customerprice_row {
467 my $customer_id = $::form->{add_customerprice};
469 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
470 or return $self->js->error(t8("No customer selected or found!"))->render;
472 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
473 $self->js->flash('info', t8("This customer has already been added."));
476 my $position = scalar @{ $self->customerprices } + 1;
478 my $cu = SL::DB::PartCustomerPrice->new(
479 customer_id => $customer->id,
480 customer_partnumber => '',
482 sortorder => $position,
483 ) or die "Can't create Customerprice object";
485 my $row_as_html = $self->p->render(
486 'part/_customerprice_row',
487 customerprice => $cu,
488 listrow => $position % 2 ? 0
492 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
493 ->val('.add_customerprice_input', '')
494 ->run('kivi.Part.focus_last_customerprice_input')->render;
497 sub action_reorder_items {
500 my $part_type = $::form->{part_type};
503 partnumber => sub { $_[0]->part->partnumber },
504 description => sub { $_[0]->part->description },
505 qty => sub { $_[0]->qty },
506 sellprice => sub { $_[0]->part->sellprice },
507 lastcost => sub { $_[0]->part->lastcost },
508 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
511 my $method = $sort_keys{$::form->{order_by}};
514 if ($part_type eq 'assortment') {
515 @items = @{ $self->assortment_items };
517 @items = @{ $self->assembly_items };
520 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
521 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
522 if ($::form->{sort_dir}) {
523 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
525 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
528 if ($::form->{sort_dir}) {
529 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
531 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
535 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
538 sub action_warehouse_changed {
541 if ($::form->{warehouse_id} ) {
542 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
543 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
545 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
546 $self->bin($self->warehouse->bins->[0]);
548 ->html('#bin', $self->build_bin_select)
549 ->focus('#part_bin_id');
550 return $self->js->render;
554 # no warehouse was selected, empty the bin field and reset the id
556 ->val('#part_bin_id', undef)
559 return $self->js->render;
562 sub action_ajax_autocomplete {
563 my ($self, %params) = @_;
565 # if someone types something, and hits enter, assume he entered the full name.
566 # if something matches, treat that as sole match
567 # since we need a second get models instance with different filters for that,
568 # we only modify the original filter temporarily in place
569 if ($::form->{prefer_exact}) {
570 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
572 my $exact_models = SL::Controller::Helper::GetModels->new(
575 paginated => { per_page => 2 },
576 with_objects => [ qw(unit_obj classification) ],
579 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
580 $self->parts($exact_matches);
586 value => $_->displayable_name,
587 label => $_->displayable_name,
589 partnumber => $_->partnumber,
590 description => $_->description,
592 part_type => $_->part_type,
594 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
596 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
598 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
601 sub action_test_page {
602 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
605 sub action_part_picker_search {
606 $_[0]->render('part/part_picker_search', { layout => 0 });
609 sub action_part_picker_result {
610 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
616 if ($::request->type eq 'json') {
621 $part_hash = $self->part->as_tree;
622 $part_hash->{cvars} = $self->part->cvar_as_hashref;
625 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
630 sub validate_add_items {
631 scalar @{$::form->{add_items}};
634 sub prepare_assortment_render_vars {
637 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
638 items_lastcost_sum => $self->part->items_lastcost_sum,
639 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
641 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
646 sub prepare_assembly_render_vars {
649 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
650 items_lastcost_sum => $self->part->items_lastcost_sum,
651 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
653 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
661 check_has_valid_part_type($self->part->part_type);
663 $self->_set_javascript;
664 $self->_setup_form_action_bar;
666 my %title_hash = ( part => t8('Add Part'),
667 assembly => t8('Add Assembly'),
668 service => t8('Add Service'),
669 assortment => t8('Add Assortment'),
674 title => $title_hash{$self->part->part_type},
679 sub _set_javascript {
681 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
682 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
685 sub recalc_item_totals {
686 my ($self, %params) = @_;
688 if ( $params{part_type} eq 'assortment' ) {
689 return 0 unless scalar @{$self->assortment_items};
690 } elsif ( $params{part_type} eq 'assembly' ) {
691 return 0 unless scalar @{$self->assembly_items};
693 carp "can only calculate sum for assortments and assemblies";
696 my $part = SL::DB::Part->new(part_type => $params{part_type});
697 if ( $part->is_assortment ) {
698 $part->assortment_items( @{$self->assortment_items} );
699 if ( $params{price_type} eq 'lastcost' ) {
700 return $part->items_lastcost_sum;
702 if ( $params{pricegroup_id} ) {
703 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
705 return $part->items_sellprice_sum;
708 } elsif ( $part->is_assembly ) {
709 $part->assemblies( @{$self->assembly_items} );
710 if ( $params{price_type} eq 'lastcost' ) {
711 return $part->items_lastcost_sum;
713 return $part->items_sellprice_sum;
718 sub check_part_not_modified {
721 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
728 my $is_new = !$self->part->id;
730 my $params = delete($::form->{part}) || { };
732 delete $params->{id};
733 $self->part->assign_attributes(%{ $params});
734 $self->part->bin_id(undef) unless $self->part->warehouse_id;
736 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
737 # will be the case for used assortments when saving, or when a used assortment
739 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
740 $self->part->assortment_items([]);
741 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
744 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
745 $self->part->assemblies([]); # completely rewrite assortments each time
746 $self->part->add_assemblies( @{ $self->assembly_items } );
749 $self->part->translations([]);
750 $self->parse_form_translations;
752 $self->part->prices([]);
753 $self->parse_form_prices;
755 $self->parse_form_customerprices;
756 $self->parse_form_makemodels;
759 sub parse_form_prices {
761 # only save prices > 0
762 my $prices = delete($::form->{prices}) || [];
763 foreach my $price ( @{$prices} ) {
764 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
765 next unless $sellprice > 0; # skip negative prices as well
766 my $p = SL::DB::Price->new(parts_id => $self->part->id,
767 pricegroup_id => $price->{pricegroup_id},
770 $self->part->add_prices($p);
774 sub parse_form_translations {
776 # don't add empty translations
777 my $translations = delete($::form->{translations}) || [];
778 foreach my $translation ( @{$translations} ) {
779 next unless $translation->{translation};
780 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
781 $self->part->add_translations( $translation );
785 sub parse_form_makemodels {
789 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
790 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
793 $self->part->makemodels([]);
796 my $makemodels = delete($::form->{makemodels}) || [];
797 foreach my $makemodel ( @{$makemodels} ) {
798 next unless $makemodel->{make};
800 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
802 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
803 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
805 make => $makemodel->{make},
806 model => $makemodel->{model} || '',
807 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
808 sortorder => $position,
810 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
811 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
812 # don't change lastupdate
813 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
814 # new makemodel, no lastcost entered, leave lastupdate empty
815 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
816 # lastcost hasn't changed, use original lastupdate
817 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
819 $mm->lastupdate(DateTime->now);
821 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
822 $self->part->add_makemodels($mm);
826 sub parse_form_customerprices {
829 my $customerprices_map;
830 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
831 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
834 $self->part->customerprices([]);
837 my $customerprices = delete($::form->{customerprices}) || [];
838 foreach my $customerprice ( @{$customerprices} ) {
839 next unless $customerprice->{customer_id};
841 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
843 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
844 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
846 customer_id => $customerprice->{customer_id},
847 customer_partnumber => $customerprice->{customer_partnumber} || '',
848 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
849 sortorder => $position,
851 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
852 # lastupdate isn't set, original price is 0 and new lastcost is 0
853 # don't change lastupdate
854 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
855 # new customerprice, no lastcost entered, leave lastupdate empty
856 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
857 # price hasn't changed, use original lastupdate
858 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
860 $cu->lastupdate(DateTime->now);
862 $self->part->add_customerprices($cu);
866 sub build_bin_select {
867 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
868 title_key => 'description',
869 default => $_[0]->bin->id,
874 # get_set_inits for partpicker
877 if ($::form->{no_paginate}) {
878 $_[0]->models->disable_plugin('paginated');
884 # get_set_inits for part controller
888 # used by edit, save, delete and add
890 if ( $::form->{part}{id} ) {
891 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
893 die "part_type missing" unless $::form->{part}{part_type};
894 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
900 return $self->part->orphaned;
906 SL::Controller::Helper::GetModels->new(
913 partnumber => t8('Partnumber'),
914 description => t8('Description'),
916 with_objects => [ qw(unit_obj classification) ],
925 sub init_assortment_items {
926 # this init is used while saving and whenever assortments change dynamically
930 my $assortment_items = delete($::form->{assortment_items}) || [];
931 foreach my $assortment_item ( @{$assortment_items} ) {
932 next unless $assortment_item->{parts_id};
934 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
935 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
936 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
937 charge => $assortment_item->{charge},
938 unit => $assortment_item->{unit} || $part->unit,
939 position => $position,
947 sub init_makemodels {
951 my @makemodel_array = ();
952 my $makemodels = delete($::form->{makemodels}) || [];
954 foreach my $makemodel ( @{$makemodels} ) {
955 next unless $makemodel->{make};
957 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
958 id => $makemodel->{id},
959 make => $makemodel->{make},
960 model => $makemodel->{model} || '',
961 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
962 sortorder => $position,
963 ) or die "Can't create mm";
964 # $mm->id($makemodel->{id}) if $makemodel->{id};
965 push(@makemodel_array, $mm);
967 return \@makemodel_array;
970 sub init_customerprices {
974 my @customerprice_array = ();
975 my $customerprices = delete($::form->{customerprices}) || [];
977 foreach my $customerprice ( @{$customerprices} ) {
978 next unless $customerprice->{customer_id};
980 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
981 id => $customerprice->{id},
982 customer_partnumber => $customerprice->{customer_partnumber},
983 customer_id => $customerprice->{customer_id} || '',
984 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
985 sortorder => $position,
986 ) or die "Can't create cu";
987 # $cu->id($customerprice->{id}) if $customerprice->{id};
988 push(@customerprice_array, $cu);
990 return \@customerprice_array;
993 sub init_assembly_items {
997 my $assembly_items = delete($::form->{assembly_items}) || [];
998 foreach my $assembly_item ( @{$assembly_items} ) {
999 next unless $assembly_item->{parts_id};
1001 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1002 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1003 bom => $assembly_item->{bom},
1004 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1005 position => $position,
1012 sub init_all_warehouses {
1014 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1017 sub init_all_languages {
1018 SL::DB::Manager::Language->get_all_sorted;
1021 sub init_all_partsgroups {
1023 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1026 sub init_all_buchungsgruppen {
1028 if ( $self->part->orphaned ) {
1029 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1031 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1035 sub init_shops_not_assigned {
1038 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1039 if ( @used_shop_ids ) {
1040 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1043 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1047 sub init_all_units {
1049 if ( $self->part->orphaned ) {
1050 return SL::DB::Manager::Unit->get_all_sorted;
1052 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1056 sub init_all_payment_terms {
1058 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1061 sub init_all_price_factors {
1062 SL::DB::Manager::PriceFactor->get_all_sorted;
1065 sub init_all_pricegroups {
1066 SL::DB::Manager::Pricegroup->get_all_sorted;
1069 # model used to filter/display the parts in the multi-items dialog
1070 sub init_multi_items_models {
1071 SL::Controller::Helper::GetModels->new(
1072 controller => $_[0],
1074 with_objects => [ qw(unit_obj partsgroup classification) ],
1075 disable_plugin => 'paginated',
1076 source => $::form->{multi_items},
1082 partnumber => t8('Partnumber'),
1083 description => t8('Description')}
1087 sub init_parts_classification_filter {
1088 return [] unless $::form->{parts_classification_type};
1090 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1091 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1093 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1096 # simple checks to run on $::form before saving
1098 sub form_check_part_description_exists {
1101 return 1 if $::form->{part}{description};
1103 $self->js->flash('error', t8('Part Description missing!'))
1104 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1105 ->focus('#part_description');
1109 sub form_check_assortment_items_exist {
1112 return 1 unless $::form->{part}{part_type} eq 'assortment';
1113 # skip item check for existing assortments that have been used
1114 return 1 if ($self->part->id and !$self->part->orphaned);
1116 # new or orphaned parts must have items in $::form->{assortment_items}
1117 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1118 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1119 ->focus('#add_assortment_item_name')
1120 ->flash('error', t8('The assortment doesn\'t have any items.'));
1126 sub form_check_assortment_items_unique {
1129 return 1 unless $::form->{part}{part_type} eq 'assortment';
1131 my %duplicate_elements;
1133 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1134 $duplicate_elements{$_}++ if $count{$_}++;
1137 if ( keys %duplicate_elements ) {
1138 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1139 ->flash('error', t8('There are duplicate assortment items'));
1145 sub form_check_assembly_items_exist {
1148 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1150 # skip item check for existing assembly that have been used
1151 return 1 if ($self->part->id and !$self->part->orphaned);
1153 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1154 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1155 ->focus('#add_assembly_item_name')
1156 ->flash('error', t8('The assembly doesn\'t have any items.'));
1162 sub form_check_partnumber_is_unique {
1165 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1166 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1168 $self->js->flash('error', t8('The partnumber already exists!'))
1169 ->focus('#part_description');
1176 # general checking functions
1179 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1185 $self->form_check_part_description_exists || return 0;
1186 $self->form_check_assortment_items_exist || return 0;
1187 $self->form_check_assortment_items_unique || return 0;
1188 $self->form_check_assembly_items_exist || return 0;
1189 $self->form_check_partnumber_is_unique || return 0;
1194 sub check_has_valid_part_type {
1195 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1199 sub normalize_text_blocks {
1202 # check if feature is enabled (select normalize_part_descriptions from defaults)
1203 return unless ($::instance_conf->get_normalize_part_descriptions);
1206 foreach (qw(description)) {
1207 $self->part->{$_} =~ s/\s+$//s;
1208 $self->part->{$_} =~ s/^\s+//s;
1209 $self->part->{$_} =~ s/ {2,}/ /g;
1211 # html block (caveat: can be circumvented by using bold or italics)
1212 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1213 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1217 sub render_assortment_items_to_html {
1218 my ($self, $assortment_items, $number_of_items) = @_;
1220 my $position = $number_of_items + 1;
1222 foreach my $ai (@$assortment_items) {
1223 $html .= $self->p->render('part/_assortment_row',
1224 PART => $self->part,
1225 orphaned => $self->orphaned,
1227 listrow => $position % 2 ? 1 : 0,
1228 position => $position, # for legacy assemblies
1235 sub render_assembly_items_to_html {
1236 my ($self, $assembly_items, $number_of_items) = @_;
1238 my $position = $number_of_items + 1;
1240 foreach my $ai (@{$assembly_items}) {
1241 $html .= $self->p->render('part/_assembly_row',
1242 PART => $self->part,
1243 orphaned => $self->orphaned,
1245 listrow => $position % 2 ? 1 : 0,
1246 position => $position, # for legacy assemblies
1253 sub parse_add_items_to_objects {
1254 my ($self, %params) = @_;
1255 my $part_type = $params{part_type};
1256 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1257 my $position = $params{position} || 1;
1259 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1262 foreach my $item ( @add_items ) {
1263 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1265 if ( $part_type eq 'assortment' ) {
1266 $ai = SL::DB::AssortmentItem->new(part => $part,
1267 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1268 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1269 position => $position,
1270 ) or die "Can't create AssortmentItem from item";
1271 } elsif ( $part_type eq 'assembly' ) {
1272 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1273 # id => $self->assembly->id, # will be set on save
1274 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1275 bom => 0, # default when adding: no bom
1276 position => $position,
1279 die "part_type must be assortment or assembly";
1281 push(@item_objects, $ai);
1285 return \@item_objects;
1288 sub _setup_form_action_bar {
1291 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1292 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1294 for my $bar ($::request->layout->get('actionbar')) {
1299 call => [ 'kivi.Part.save' ],
1300 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1304 call => [ 'kivi.Part.use_as_new' ],
1305 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1306 : !$may_edit ? t8('You do not have the permissions to access this function.')
1309 ], # end of combobox "Save"
1313 call => [ 'kivi.Part.delete' ],
1314 confirm => t8('Do you really want to delete this object?'),
1315 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1316 : !$may_edit ? t8('You do not have the permissions to access this function.')
1317 : !$self->part->orphaned ? t8('This object has already been used.')
1318 : $used_in_pricerules ? t8('This object is used in price rules.')
1326 call => [ 'kivi.Part.open_history_popup' ],
1327 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1328 : !$may_edit ? t8('You do not have the permissions to access this function.')
1343 SL::Controller::Part - Part CRUD controller
1347 Controller for adding/editing/saving/deleting parts.
1349 All the relations are loaded at once and saving the part, adding a history
1350 entry and saving CVars happens inside one transaction. When saving the old
1351 relations are deleted and written as new to the database.
1353 Relations for parts:
1361 =item assembly items
1363 =item assortment items
1371 There are 4 different part types:
1377 The "default" part type.
1379 inventory_accno_id is set.
1383 Services can't be stocked.
1385 inventory_accno_id isn't set.
1389 Assemblies consist of other parts, services, assemblies or assortments. They
1390 aren't meant to be bought, only sold. To add assemblies to stock you typically
1391 have to make them, which reduces the stock by its respective components. Once
1392 an assembly item has been created there is currently no way to "disassemble" it
1393 again. An assembly item can appear several times in one assembly. An assmbly is
1394 sold as one item with a defined sellprice and lastcost. If the component prices
1395 change the assortment price remains the same. The assembly items may be printed
1396 in a record if the item's "bom" is set.
1400 Similar to assembly, but each assortment item may only appear once per
1401 assortment. When selling an assortment the assortment items are added to the
1402 record together with the assortment, which is added with sellprice 0.
1404 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1405 determined by the sum of the current assortment item prices when the assortment
1406 is added to a record. This also means that price rules and customer discounts
1407 will be applied to the assortment items.
1409 Once the assortment items have been added they may be modified or deleted, just
1410 as if they had been added manually, the individual assortment items aren't
1411 linked to the assortment or the other assortment items in any way.
1419 =item C<action_add_part>
1421 =item C<action_add_service>
1423 =item C<action_add_assembly>
1425 =item C<action_add_assortment>
1427 =item C<action_add PART_TYPE>
1429 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1430 parameter part_type as an action. Example:
1432 controller.pl?action=Part/add&part_type=service
1434 =item C<action_add_from_record>
1436 When adding new items to records they can be created on the fly if the entered
1437 partnumber or description doesn't exist yet. After being asked what part type
1438 the new item should have the user is redirected to the correct edit page.
1440 Depending on whether the item was added from a sales or a purchase record, only
1441 the relevant part classifications should be selectable for new item, so this
1442 parameter is passed on via a hidden parts_classification_type in the new_item
1445 =item C<action_save>
1447 Saves the current part and then reloads the edit page for the part.
1449 =item C<action_use_as_new>
1451 Takes the information from the current part, plus any modifications made on the
1452 page, and creates a new edit page that is ready to be saved. The partnumber is
1453 set empty, so a new partnumber from the number range will be used if the user
1454 doesn't enter one manually.
1456 Unsaved changes to the original part aren't updated.
1458 The part type cannot be changed in this way.
1460 =item C<action_delete>
1462 Deletes the current part and then redirects to the main page, there is no
1465 The delete button only appears if the part is 'orphaned', according to
1466 SL::DB::Part orphaned.
1468 The part can't be deleted if it appears in invoices, orders, delivery orders,
1469 the inventory, or is part of an assembly or assortment.
1471 If the part is deleted its relations prices, makdemodel, assembly,
1472 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1474 Before this controller items that appeared in inventory didn't count as
1475 orphaned and could be deleted and the inventory entries were also deleted, this
1476 "feature" hasn't been implemented.
1478 =item C<action_edit part.id>
1480 Load and display a part for editing.
1482 controller.pl?action=Part/edit&part.id=12345
1484 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1488 =head1 BUTTON ACTIONS
1494 Opens a popup displaying all the history entries. Once a new history controller
1495 is written the button could link there instead, with the part already selected.
1503 =item C<action_update_item_totals>
1505 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1506 amount of an item changes. The sum of all sellprices and lastcosts is
1507 calculated and the totals updated. Uses C<recalc_item_totals>.
1509 =item C<action_add_assortment_item>
1511 Adds a new assortment item from a part picker seleciton to the assortment item list
1513 If the item already exists in the assortment the item isn't added and a Flash
1516 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1517 after adding each new item, add the new object to the item objects that were
1518 already parsed, calculate totals via a dummy part then update the row and the
1521 =item C<action_add_assembly_item>
1523 Adds a new assembly item from a part picker seleciton to the assembly item list
1525 If the item already exists in the assembly a flash info is generated, but the
1528 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1529 after adding each new item, add the new object to the item objects that were
1530 already parsed, calculate totals via a dummy part then update the row and the
1533 =item C<action_add_multi_assortment_items>
1535 Parses the items to be added from the form generated by the multi input and
1536 appends the html of the tr-rows to the assortment item table. Afterwards all
1537 assortment items are renumbered and the sums recalculated via
1538 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1540 =item C<action_add_multi_assembly_items>
1542 Parses the items to be added from the form generated by the multi input and
1543 appends the html of the tr-rows to the assembly item table. Afterwards all
1544 assembly items are renumbered and the sums recalculated via
1545 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1547 =item C<action_show_multi_items_dialog>
1549 =item C<action_multi_items_update_result>
1551 =item C<action_add_makemodel_row>
1553 Add a new makemodel row with the vendor that was selected via the vendor
1556 Checks the already existing makemodels and warns if a row with that vendor
1557 already exists. Currently it is possible to have duplicate vendor rows.
1559 =item C<action_reorder_items>
1561 Sorts the item table for assembly or assortment items.
1563 =item C<action_warehouse_changed>
1567 =head1 ACTIONS part picker
1571 =item C<action_ajax_autocomplete>
1573 =item C<action_test_page>
1575 =item C<action_part_picker_search>
1577 =item C<action_part_picker_result>
1579 =item C<action_show>
1589 Calls some simple checks that test the submitted $::form for obvious errors.
1590 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1592 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1593 some cases extra actions are taken, e.g. if the part description is missing the
1594 basic data tab is selected and the description input field is focussed.
1600 =item C<form_check_part_description_exists>
1602 =item C<form_check_assortment_items_exist>
1604 =item C<form_check_assortment_items_unique>
1606 =item C<form_check_assembly_items_exist>
1608 =item C<form_check_partnumber_is_unique>
1612 =head1 HELPER FUNCTIONS
1618 When submitting the form for saving, parses the transmitted form. Expects the
1622 $::form->{makemodels}
1623 $::form->{translations}
1625 $::form->{assemblies}
1626 $::form->{assortments}
1628 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1630 =item C<recalc_item_totals %params>
1632 Helper function for calculating the total lastcost and sellprice for assemblies
1633 or assortments according to their items, which are parsed from the current
1636 Is called whenever the qty of an item is changed or items are deleted.
1640 * part_type : 'assortment' or 'assembly' (mandatory)
1642 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1644 Depending on the price_type the lastcost sum or sellprice sum is returned.
1646 Doesn't work for recursive items.
1650 =head1 GET SET INITS
1652 There are get_set_inits for
1660 which parse $::form and automatically create an array of objects.
1662 These inits are used during saving and each time a new element is added.
1666 =item C<init_makemodels>
1668 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1669 $self->part->makemodels, ready to be saved.
1671 Used for saving parts and adding new makemodel rows.
1673 =item C<parse_add_items_to_objects PART_TYPE>
1675 Parses the resulting form from either the part-picker submit or the multi-item
1676 submit, and creates an arrayref of assortment_item or assembly objects, that
1677 can be rendered via C<render_assortment_items_to_html> or
1678 C<render_assembly_items_to_html>.
1680 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1681 Optional param: position (used for numbering and listrow class)
1683 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1685 Takes an array_ref of assortment_items, and generates tables rows ready for
1686 adding to the assortment table. Is used when a part is loaded, or whenever new
1687 assortment items are added.
1689 =item C<parse_form_makemodels>
1691 Makemodels can't just be overwritten, because of the field "lastupdate", that
1692 remembers when the lastcost for that vendor changed the last time.
1694 So the original values are cloned and remembered, so we can compare if lastcost
1695 was changed in $::form, and keep or update lastupdate.
1697 lastcost isn't updated until the first time it was saved with a value, until
1700 Also a boolean "makemodel" needs to be written in parts, depending on whether
1701 makemodel entries exist or not.
1703 We still need init_makemodels for when we open the part for editing.
1713 It should be possible to jump to the edit page in a specific tab
1717 Support callbacks, e.g. creating a new part from within an order, and jumping
1718 back to the order again afterwards.
1722 Support units when adding assembly items or assortment items. Currently the
1723 default unit of the item is always used.
1727 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1728 consists of other assemblies.
1734 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>