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 my $count = $_[0]->multi_items_models->count;
413 my $text = escape($::locale->text('No results.'));
414 $_[0]->render($text, { layout => 0 });
415 } elsif ($count > $max_count) {
416 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
417 $_[0]->render($text, { layout => 0 });
419 my $multi_items = $_[0]->multi_items_models->get;
420 $_[0]->render('part/_multi_items_result', { layout => 0 },
421 multi_items => $multi_items);
425 sub action_add_makemodel_row {
428 my $vendor_id = $::form->{add_makemodel};
430 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
431 return $self->js->error(t8("No vendor selected or found!"))->render;
433 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
434 $self->js->flash('info', t8("This vendor has already been added."));
437 my $position = scalar @{$self->makemodels} + 1;
439 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
443 sortorder => $position,
444 ) or die "Can't create MakeModel object";
446 my $row_as_html = $self->p->render('part/_makemodel_row',
448 listrow => $position % 2 ? 0 : 1,
451 # after selection focus on the model field in the row that was just added
453 ->append('#makemodel_rows', $row_as_html) # append in tbody
454 ->val('.add_makemodel_input', '')
455 ->run('kivi.Part.focus_last_makemodel_input')
459 sub action_add_customerprice_row {
462 my $customer_id = $::form->{add_customerprice};
464 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
465 or return $self->js->error(t8("No customer selected or found!"))->render;
467 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
468 $self->js->flash('info', t8("This customer has already been added."));
471 my $position = scalar @{ $self->customerprices } + 1;
473 my $cu = SL::DB::PartCustomerPrice->new(
474 customer_id => $customer->id,
475 customer_partnumber => '',
477 sortorder => $position,
478 ) or die "Can't create Customerprice object";
480 my $row_as_html = $self->p->render(
481 'part/_customerprice_row',
482 customerprice => $cu,
483 listrow => $position % 2 ? 0
487 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
488 ->val('.add_customerprice_input', '')
489 ->run('kivi.Part.focus_last_customerprice_input')->render;
492 sub action_reorder_items {
495 my $part_type = $::form->{part_type};
498 partnumber => sub { $_[0]->part->partnumber },
499 description => sub { $_[0]->part->description },
500 qty => sub { $_[0]->qty },
501 sellprice => sub { $_[0]->part->sellprice },
502 lastcost => sub { $_[0]->part->lastcost },
503 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
506 my $method = $sort_keys{$::form->{order_by}};
509 if ($part_type eq 'assortment') {
510 @items = @{ $self->assortment_items };
512 @items = @{ $self->assembly_items };
515 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
516 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
517 if ($::form->{sort_dir}) {
518 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
520 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
523 if ($::form->{sort_dir}) {
524 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
526 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
530 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
533 sub action_warehouse_changed {
536 if ($::form->{warehouse_id} ) {
537 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
538 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
540 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
541 $self->bin($self->warehouse->bins->[0]);
543 ->html('#bin', $self->build_bin_select)
544 ->focus('#part_bin_id');
545 return $self->js->render;
549 # no warehouse was selected, empty the bin field and reset the id
551 ->val('#part_bin_id', undef)
554 return $self->js->render;
557 sub action_ajax_autocomplete {
558 my ($self, %params) = @_;
560 # if someone types something, and hits enter, assume he entered the full name.
561 # if something matches, treat that as sole match
562 # since we need a second get models instance with different filters for that,
563 # we only modify the original filter temporarily in place
564 if ($::form->{prefer_exact}) {
565 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
567 my $exact_models = SL::Controller::Helper::GetModels->new(
570 paginated => { per_page => 2 },
571 with_objects => [ qw(unit_obj classification) ],
574 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
575 $self->parts($exact_matches);
581 value => $_->displayable_name,
582 label => $_->displayable_name,
584 partnumber => $_->partnumber,
585 description => $_->description,
587 part_type => $_->part_type,
589 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
591 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
593 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
596 sub action_test_page {
597 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
600 sub action_part_picker_search {
601 $_[0]->render('part/part_picker_search', { layout => 0 });
604 sub action_part_picker_result {
605 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
611 if ($::request->type eq 'json') {
616 $part_hash = $self->part->as_tree;
617 $part_hash->{cvars} = $self->part->cvar_as_hashref;
620 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
625 sub validate_add_items {
626 scalar @{$::form->{add_items}};
629 sub prepare_assortment_render_vars {
632 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
633 items_lastcost_sum => $self->part->items_lastcost_sum,
634 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
636 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
641 sub prepare_assembly_render_vars {
644 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
646 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
647 items_lastcost_sum => $self->part->items_lastcost_sum,
648 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
650 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
658 check_has_valid_part_type($self->part->part_type);
660 $self->_set_javascript;
661 $self->_setup_form_action_bar;
663 my %title_hash = ( part => t8('Add Part'),
664 assembly => t8('Add Assembly'),
665 service => t8('Add Service'),
666 assortment => t8('Add Assortment'),
671 title => $title_hash{$self->part->part_type},
676 sub _set_javascript {
678 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
679 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
682 sub recalc_item_totals {
683 my ($self, %params) = @_;
685 if ( $params{part_type} eq 'assortment' ) {
686 return 0 unless scalar @{$self->assortment_items};
687 } elsif ( $params{part_type} eq 'assembly' ) {
688 return 0 unless scalar @{$self->assembly_items};
690 carp "can only calculate sum for assortments and assemblies";
693 my $part = SL::DB::Part->new(part_type => $params{part_type});
694 if ( $part->is_assortment ) {
695 $part->assortment_items( @{$self->assortment_items} );
696 if ( $params{price_type} eq 'lastcost' ) {
697 return $part->items_lastcost_sum;
699 if ( $params{pricegroup_id} ) {
700 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
702 return $part->items_sellprice_sum;
705 } elsif ( $part->is_assembly ) {
706 $part->assemblies( @{$self->assembly_items} );
707 if ( $params{price_type} eq 'lastcost' ) {
708 return $part->items_lastcost_sum;
710 return $part->items_sellprice_sum;
715 sub check_part_not_modified {
718 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
725 my $is_new = !$self->part->id;
727 my $params = delete($::form->{part}) || { };
729 delete $params->{id};
730 $self->part->assign_attributes(%{ $params});
731 $self->part->bin_id(undef) unless $self->part->warehouse_id;
733 $self->normalize_text_blocks;
735 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
736 # will be the case for used assortments when saving, or when a used assortment
738 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
739 $self->part->assortment_items([]);
740 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
743 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
744 $self->part->assemblies([]); # completely rewrite assortments each time
745 $self->part->add_assemblies( @{ $self->assembly_items } );
748 $self->part->translations([]);
749 $self->parse_form_translations;
751 $self->part->prices([]);
752 $self->parse_form_prices;
754 $self->parse_form_customerprices;
755 $self->parse_form_makemodels;
758 sub parse_form_prices {
760 # only save prices > 0
761 my $prices = delete($::form->{prices}) || [];
762 foreach my $price ( @{$prices} ) {
763 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
764 next unless $sellprice > 0; # skip negative prices as well
765 my $p = SL::DB::Price->new(parts_id => $self->part->id,
766 pricegroup_id => $price->{pricegroup_id},
769 $self->part->add_prices($p);
773 sub parse_form_translations {
775 # don't add empty translations
776 my $translations = delete($::form->{translations}) || [];
777 foreach my $translation ( @{$translations} ) {
778 next unless $translation->{translation};
779 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
780 $self->part->add_translations( $translation );
784 sub parse_form_makemodels {
788 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
789 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
792 $self->part->makemodels([]);
795 my $makemodels = delete($::form->{makemodels}) || [];
796 foreach my $makemodel ( @{$makemodels} ) {
797 next unless $makemodel->{make};
799 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
801 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
802 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
804 make => $makemodel->{make},
805 model => $makemodel->{model} || '',
806 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
807 sortorder => $position,
809 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
810 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
811 # don't change lastupdate
812 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
813 # new makemodel, no lastcost entered, leave lastupdate empty
814 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
815 # lastcost hasn't changed, use original lastupdate
816 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
818 $mm->lastupdate(DateTime->now);
820 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
821 $self->part->add_makemodels($mm);
825 sub parse_form_customerprices {
828 my $customerprices_map;
829 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
830 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
833 $self->part->customerprices([]);
836 my $customerprices = delete($::form->{customerprices}) || [];
837 foreach my $customerprice ( @{$customerprices} ) {
838 next unless $customerprice->{customer_id};
840 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
842 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
843 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
845 customer_id => $customerprice->{customer_id},
846 customer_partnumber => $customerprice->{customer_partnumber} || '',
847 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
848 sortorder => $position,
850 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
851 # lastupdate isn't set, original price is 0 and new lastcost is 0
852 # don't change lastupdate
853 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
854 # new customerprice, no lastcost entered, leave lastupdate empty
855 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
856 # price hasn't changed, use original lastupdate
857 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
859 $cu->lastupdate(DateTime->now);
861 $self->part->add_customerprices($cu);
865 sub build_bin_select {
866 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
867 title_key => 'description',
868 default => $_[0]->bin->id,
873 # get_set_inits for partpicker
876 if ($::form->{no_paginate}) {
877 $_[0]->models->disable_plugin('paginated');
883 # get_set_inits for part controller
887 # used by edit, save, delete and add
889 if ( $::form->{part}{id} ) {
890 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
892 die "part_type missing" unless $::form->{part}{part_type};
893 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
899 return $self->part->orphaned;
905 SL::Controller::Helper::GetModels->new(
912 partnumber => t8('Partnumber'),
913 description => t8('Description'),
915 with_objects => [ qw(unit_obj classification) ],
924 sub init_assortment_items {
925 # this init is used while saving and whenever assortments change dynamically
929 my $assortment_items = delete($::form->{assortment_items}) || [];
930 foreach my $assortment_item ( @{$assortment_items} ) {
931 next unless $assortment_item->{parts_id};
933 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
934 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
935 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
936 charge => $assortment_item->{charge},
937 unit => $assortment_item->{unit} || $part->unit,
938 position => $position,
946 sub init_makemodels {
950 my @makemodel_array = ();
951 my $makemodels = delete($::form->{makemodels}) || [];
953 foreach my $makemodel ( @{$makemodels} ) {
954 next unless $makemodel->{make};
956 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
957 id => $makemodel->{id},
958 make => $makemodel->{make},
959 model => $makemodel->{model} || '',
960 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
961 sortorder => $position,
962 ) or die "Can't create mm";
963 # $mm->id($makemodel->{id}) if $makemodel->{id};
964 push(@makemodel_array, $mm);
966 return \@makemodel_array;
969 sub init_customerprices {
973 my @customerprice_array = ();
974 my $customerprices = delete($::form->{customerprices}) || [];
976 foreach my $customerprice ( @{$customerprices} ) {
977 next unless $customerprice->{customer_id};
979 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
980 id => $customerprice->{id},
981 customer_partnumber => $customerprice->{customer_partnumber},
982 customer_id => $customerprice->{customer_id} || '',
983 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
984 sortorder => $position,
985 ) or die "Can't create cu";
986 # $cu->id($customerprice->{id}) if $customerprice->{id};
987 push(@customerprice_array, $cu);
989 return \@customerprice_array;
992 sub init_assembly_items {
996 my $assembly_items = delete($::form->{assembly_items}) || [];
997 foreach my $assembly_item ( @{$assembly_items} ) {
998 next unless $assembly_item->{parts_id};
1000 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1001 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1002 bom => $assembly_item->{bom},
1003 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1004 position => $position,
1011 sub init_all_warehouses {
1013 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1016 sub init_all_languages {
1017 SL::DB::Manager::Language->get_all_sorted;
1020 sub init_all_partsgroups {
1022 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1025 sub init_all_buchungsgruppen {
1027 if ( $self->part->orphaned ) {
1028 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1030 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1034 sub init_shops_not_assigned {
1037 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1038 if ( @used_shop_ids ) {
1039 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1042 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1046 sub init_all_units {
1048 if ( $self->part->orphaned ) {
1049 return SL::DB::Manager::Unit->get_all_sorted;
1051 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1055 sub init_all_payment_terms {
1057 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1060 sub init_all_price_factors {
1061 SL::DB::Manager::PriceFactor->get_all_sorted;
1064 sub init_all_pricegroups {
1065 SL::DB::Manager::Pricegroup->get_all_sorted;
1068 # model used to filter/display the parts in the multi-items dialog
1069 sub init_multi_items_models {
1070 SL::Controller::Helper::GetModels->new(
1071 controller => $_[0],
1073 with_objects => [ qw(unit_obj partsgroup classification) ],
1074 disable_plugin => 'paginated',
1075 source => $::form->{multi_items},
1081 partnumber => t8('Partnumber'),
1082 description => t8('Description')}
1086 sub init_parts_classification_filter {
1087 return [] unless $::form->{parts_classification_type};
1089 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1090 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1092 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1095 # simple checks to run on $::form before saving
1097 sub form_check_part_description_exists {
1100 return 1 if $::form->{part}{description};
1102 $self->js->flash('error', t8('Part Description missing!'))
1103 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1104 ->focus('#part_description');
1108 sub form_check_assortment_items_exist {
1111 return 1 unless $::form->{part}{part_type} eq 'assortment';
1112 # skip item check for existing assortments that have been used
1113 return 1 if ($self->part->id and !$self->part->orphaned);
1115 # new or orphaned parts must have items in $::form->{assortment_items}
1116 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1117 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1118 ->focus('#add_assortment_item_name')
1119 ->flash('error', t8('The assortment doesn\'t have any items.'));
1125 sub form_check_assortment_items_unique {
1128 return 1 unless $::form->{part}{part_type} eq 'assortment';
1130 my %duplicate_elements;
1132 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1133 $duplicate_elements{$_}++ if $count{$_}++;
1136 if ( keys %duplicate_elements ) {
1137 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1138 ->flash('error', t8('There are duplicate assortment items'));
1144 sub form_check_assembly_items_exist {
1147 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1149 # skip item check for existing assembly that have been used
1150 return 1 if ($self->part->id and !$self->part->orphaned);
1152 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1153 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1154 ->focus('#add_assembly_item_name')
1155 ->flash('error', t8('The assembly doesn\'t have any items.'));
1161 sub form_check_partnumber_is_unique {
1164 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1165 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1167 $self->js->flash('error', t8('The partnumber already exists!'))
1168 ->focus('#part_description');
1175 # general checking functions
1178 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1184 $self->form_check_part_description_exists || return 0;
1185 $self->form_check_assortment_items_exist || return 0;
1186 $self->form_check_assortment_items_unique || return 0;
1187 $self->form_check_assembly_items_exist || return 0;
1188 $self->form_check_partnumber_is_unique || return 0;
1193 sub check_has_valid_part_type {
1194 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1198 sub normalize_text_blocks {
1201 # check if feature is enabled (select normalize_part_descriptions from defaults)
1202 return unless ($::instance_conf->get_normalize_part_descriptions);
1205 foreach (qw(description)) {
1206 $self->part->{$_} =~ s/\s+$//s;
1207 $self->part->{$_} =~ s/^\s+//s;
1208 $self->part->{$_} =~ s/ {2,}/ /g;
1210 # html block (caveat: can be circumvented by using bold or italics)
1211 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1212 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1216 sub render_assortment_items_to_html {
1217 my ($self, $assortment_items, $number_of_items) = @_;
1219 my $position = $number_of_items + 1;
1221 foreach my $ai (@$assortment_items) {
1222 $html .= $self->p->render('part/_assortment_row',
1223 PART => $self->part,
1224 orphaned => $self->orphaned,
1226 listrow => $position % 2 ? 1 : 0,
1227 position => $position, # for legacy assemblies
1234 sub render_assembly_items_to_html {
1235 my ($self, $assembly_items, $number_of_items) = @_;
1237 my $position = $number_of_items + 1;
1239 foreach my $ai (@{$assembly_items}) {
1240 $html .= $self->p->render('part/_assembly_row',
1241 PART => $self->part,
1242 orphaned => $self->orphaned,
1244 listrow => $position % 2 ? 1 : 0,
1245 position => $position, # for legacy assemblies
1252 sub parse_add_items_to_objects {
1253 my ($self, %params) = @_;
1254 my $part_type = $params{part_type};
1255 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1256 my $position = $params{position} || 1;
1258 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1261 foreach my $item ( @add_items ) {
1262 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1264 if ( $part_type eq 'assortment' ) {
1265 $ai = SL::DB::AssortmentItem->new(part => $part,
1266 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1267 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1268 position => $position,
1269 ) or die "Can't create AssortmentItem from item";
1270 } elsif ( $part_type eq 'assembly' ) {
1271 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1272 # id => $self->assembly->id, # will be set on save
1273 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1274 bom => 0, # default when adding: no bom
1275 position => $position,
1278 die "part_type must be assortment or assembly";
1280 push(@item_objects, $ai);
1284 return \@item_objects;
1287 sub _setup_form_action_bar {
1290 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1291 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1293 for my $bar ($::request->layout->get('actionbar')) {
1298 call => [ 'kivi.Part.save' ],
1299 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1303 call => [ 'kivi.Part.use_as_new' ],
1304 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1305 : !$may_edit ? t8('You do not have the permissions to access this function.')
1308 ], # end of combobox "Save"
1312 call => [ 'kivi.Part.delete' ],
1313 confirm => t8('Do you really want to delete this object?'),
1314 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1315 : !$may_edit ? t8('You do not have the permissions to access this function.')
1316 : !$self->part->orphaned ? t8('This object has already been used.')
1317 : $used_in_pricerules ? t8('This object is used in price rules.')
1325 call => [ 'kivi.Part.open_history_popup' ],
1326 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1327 : !$may_edit ? t8('You do not have the permissions to access this function.')
1342 SL::Controller::Part - Part CRUD controller
1346 Controller for adding/editing/saving/deleting parts.
1348 All the relations are loaded at once and saving the part, adding a history
1349 entry and saving CVars happens inside one transaction. When saving the old
1350 relations are deleted and written as new to the database.
1352 Relations for parts:
1360 =item assembly items
1362 =item assortment items
1370 There are 4 different part types:
1376 The "default" part type.
1378 inventory_accno_id is set.
1382 Services can't be stocked.
1384 inventory_accno_id isn't set.
1388 Assemblies consist of other parts, services, assemblies or assortments. They
1389 aren't meant to be bought, only sold. To add assemblies to stock you typically
1390 have to make them, which reduces the stock by its respective components. Once
1391 an assembly item has been created there is currently no way to "disassemble" it
1392 again. An assembly item can appear several times in one assembly. An assmbly is
1393 sold as one item with a defined sellprice and lastcost. If the component prices
1394 change the assortment price remains the same. The assembly items may be printed
1395 in a record if the item's "bom" is set.
1399 Similar to assembly, but each assortment item may only appear once per
1400 assortment. When selling an assortment the assortment items are added to the
1401 record together with the assortment, which is added with sellprice 0.
1403 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1404 determined by the sum of the current assortment item prices when the assortment
1405 is added to a record. This also means that price rules and customer discounts
1406 will be applied to the assortment items.
1408 Once the assortment items have been added they may be modified or deleted, just
1409 as if they had been added manually, the individual assortment items aren't
1410 linked to the assortment or the other assortment items in any way.
1418 =item C<action_add_part>
1420 =item C<action_add_service>
1422 =item C<action_add_assembly>
1424 =item C<action_add_assortment>
1426 =item C<action_add PART_TYPE>
1428 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1429 parameter part_type as an action. Example:
1431 controller.pl?action=Part/add&part_type=service
1433 =item C<action_add_from_record>
1435 When adding new items to records they can be created on the fly if the entered
1436 partnumber or description doesn't exist yet. After being asked what part type
1437 the new item should have the user is redirected to the correct edit page.
1439 Depending on whether the item was added from a sales or a purchase record, only
1440 the relevant part classifications should be selectable for new item, so this
1441 parameter is passed on via a hidden parts_classification_type in the new_item
1444 =item C<action_save>
1446 Saves the current part and then reloads the edit page for the part.
1448 =item C<action_use_as_new>
1450 Takes the information from the current part, plus any modifications made on the
1451 page, and creates a new edit page that is ready to be saved. The partnumber is
1452 set empty, so a new partnumber from the number range will be used if the user
1453 doesn't enter one manually.
1455 Unsaved changes to the original part aren't updated.
1457 The part type cannot be changed in this way.
1459 =item C<action_delete>
1461 Deletes the current part and then redirects to the main page, there is no
1464 The delete button only appears if the part is 'orphaned', according to
1465 SL::DB::Part orphaned.
1467 The part can't be deleted if it appears in invoices, orders, delivery orders,
1468 the inventory, or is part of an assembly or assortment.
1470 If the part is deleted its relations prices, makdemodel, assembly,
1471 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1473 Before this controller items that appeared in inventory didn't count as
1474 orphaned and could be deleted and the inventory entries were also deleted, this
1475 "feature" hasn't been implemented.
1477 =item C<action_edit part.id>
1479 Load and display a part for editing.
1481 controller.pl?action=Part/edit&part.id=12345
1483 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1487 =head1 BUTTON ACTIONS
1493 Opens a popup displaying all the history entries. Once a new history controller
1494 is written the button could link there instead, with the part already selected.
1502 =item C<action_update_item_totals>
1504 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1505 amount of an item changes. The sum of all sellprices and lastcosts is
1506 calculated and the totals updated. Uses C<recalc_item_totals>.
1508 =item C<action_add_assortment_item>
1510 Adds a new assortment item from a part picker seleciton to the assortment item list
1512 If the item already exists in the assortment the item isn't added and a Flash
1515 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1516 after adding each new item, add the new object to the item objects that were
1517 already parsed, calculate totals via a dummy part then update the row and the
1520 =item C<action_add_assembly_item>
1522 Adds a new assembly item from a part picker seleciton to the assembly item list
1524 If the item already exists in the assembly a flash info is generated, but the
1527 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1528 after adding each new item, add the new object to the item objects that were
1529 already parsed, calculate totals via a dummy part then update the row and the
1532 =item C<action_add_multi_assortment_items>
1534 Parses the items to be added from the form generated by the multi input and
1535 appends the html of the tr-rows to the assortment item table. Afterwards all
1536 assortment items are renumbered and the sums recalculated via
1537 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1539 =item C<action_add_multi_assembly_items>
1541 Parses the items to be added from the form generated by the multi input and
1542 appends the html of the tr-rows to the assembly item table. Afterwards all
1543 assembly items are renumbered and the sums recalculated via
1544 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1546 =item C<action_show_multi_items_dialog>
1548 =item C<action_multi_items_update_result>
1550 =item C<action_add_makemodel_row>
1552 Add a new makemodel row with the vendor that was selected via the vendor
1555 Checks the already existing makemodels and warns if a row with that vendor
1556 already exists. Currently it is possible to have duplicate vendor rows.
1558 =item C<action_reorder_items>
1560 Sorts the item table for assembly or assortment items.
1562 =item C<action_warehouse_changed>
1566 =head1 ACTIONS part picker
1570 =item C<action_ajax_autocomplete>
1572 =item C<action_test_page>
1574 =item C<action_part_picker_search>
1576 =item C<action_part_picker_result>
1578 =item C<action_show>
1588 Calls some simple checks that test the submitted $::form for obvious errors.
1589 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1591 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1592 some cases extra actions are taken, e.g. if the part description is missing the
1593 basic data tab is selected and the description input field is focussed.
1599 =item C<form_check_part_description_exists>
1601 =item C<form_check_assortment_items_exist>
1603 =item C<form_check_assortment_items_unique>
1605 =item C<form_check_assembly_items_exist>
1607 =item C<form_check_partnumber_is_unique>
1611 =head1 HELPER FUNCTIONS
1617 When submitting the form for saving, parses the transmitted form. Expects the
1621 $::form->{makemodels}
1622 $::form->{translations}
1624 $::form->{assemblies}
1625 $::form->{assortments}
1627 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1629 =item C<recalc_item_totals %params>
1631 Helper function for calculating the total lastcost and sellprice for assemblies
1632 or assortments according to their items, which are parsed from the current
1635 Is called whenever the qty of an item is changed or items are deleted.
1639 * part_type : 'assortment' or 'assembly' (mandatory)
1641 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1643 Depending on the price_type the lastcost sum or sellprice sum is returned.
1645 Doesn't work for recursive items.
1649 =head1 GET SET INITS
1651 There are get_set_inits for
1659 which parse $::form and automatically create an array of objects.
1661 These inits are used during saving and each time a new element is added.
1665 =item C<init_makemodels>
1667 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1668 $self->part->makemodels, ready to be saved.
1670 Used for saving parts and adding new makemodel rows.
1672 =item C<parse_add_items_to_objects PART_TYPE>
1674 Parses the resulting form from either the part-picker submit or the multi-item
1675 submit, and creates an arrayref of assortment_item or assembly objects, that
1676 can be rendered via C<render_assortment_items_to_html> or
1677 C<render_assembly_items_to_html>.
1679 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1680 Optional param: position (used for numbering and listrow class)
1682 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1684 Takes an array_ref of assortment_items, and generates tables rows ready for
1685 adding to the assortment table. Is used when a part is loaded, or whenever new
1686 assortment items are added.
1688 =item C<parse_form_makemodels>
1690 Makemodels can't just be overwritten, because of the field "lastupdate", that
1691 remembers when the lastcost for that vendor changed the last time.
1693 So the original values are cloned and remembered, so we can compare if lastcost
1694 was changed in $::form, and keep or update lastupdate.
1696 lastcost isn't updated until the first time it was saved with a value, until
1699 Also a boolean "makemodel" needs to be written in parts, depending on whether
1700 makemodel entries exist or not.
1702 We still need init_makemodels for when we open the part for editing.
1712 It should be possible to jump to the edit page in a specific tab
1716 Support callbacks, e.g. creating a new part from within an order, and jumping
1717 back to the order again afterwards.
1721 Support units when adding assembly items or assortment items. Currently the
1722 default unit of the item is always used.
1726 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1727 consists of other assemblies.
1733 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>