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);
232 $_->{valid} = 1 for @{ $params{CUSTOM_VARIABLES} };
234 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
235 if (scalar @{ $params{CUSTOM_VARIABLES} });
237 my %title_hash = ( part => t8('Edit Part'),
238 assembly => t8('Edit Assembly'),
239 service => t8('Edit Service'),
240 assortment => t8('Edit Assortment'),
243 $self->part->prices([]) unless $self->part->prices;
244 $self->part->translations([]) unless $self->part->translations;
248 title => $title_hash{$self->part->part_type},
251 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
252 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
253 oldpartnumber => $::form->{oldpartnumber},
254 old_id => $::form->{old_id},
262 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
263 $_[0]->render('part/history', { layout => 0 },
264 history_entries => $history_entries);
267 sub action_update_item_totals {
270 my $part_type = $::form->{part_type};
271 die unless $part_type =~ /^(assortment|assembly)$/;
273 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
274 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
276 my $sum_diff = $sellprice_sum-$lastcost_sum;
279 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
280 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
281 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
282 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
283 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
284 ->no_flash_clear->render();
287 sub action_add_multi_assortment_items {
290 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
291 my $html = $self->render_assortment_items_to_html($item_objects);
293 $self->js->run('kivi.Part.close_picker_dialogs')
294 ->append('#assortment_rows', $html)
295 ->run('kivi.Part.renumber_positions')
296 ->run('kivi.Part.assortment_recalc')
300 sub action_add_multi_assembly_items {
303 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
305 foreach my $item (@{$item_objects}) {
306 my $errstr = validate_assembly($item->part,$self->part);
307 $self->js->flash('error',$errstr) if $errstr;
308 push (@checked_objects,$item) unless $errstr;
311 my $html = $self->render_assembly_items_to_html(\@checked_objects);
313 $self->js->run('kivi.Part.close_picker_dialogs')
314 ->append('#assembly_rows', $html)
315 ->run('kivi.Part.renumber_positions')
316 ->run('kivi.Part.assembly_recalc')
320 sub action_add_assortment_item {
321 my ($self, %params) = @_;
323 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
325 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
327 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
328 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
329 return $self->js->flash('error', t8("This part has already been added."))->render;
332 my $number_of_items = scalar @{$self->assortment_items};
333 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
334 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
336 push(@{$self->assortment_items}, @{$item_objects});
337 my $part = SL::DB::Part->new(part_type => 'assortment');
338 $part->assortment_items(@{$self->assortment_items});
339 my $items_sellprice_sum = $part->items_sellprice_sum;
340 my $items_lastcost_sum = $part->items_lastcost_sum;
341 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
344 ->append('#assortment_rows' , $html) # append in tbody
345 ->val('.add_assortment_item_input' , '')
346 ->run('kivi.Part.focus_last_assortment_input')
347 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
348 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
349 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
350 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
351 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
355 sub action_add_assembly_item {
358 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
360 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
362 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
364 my $duplicate_warning = 0; # duplicates are allowed, just warn
365 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
366 $duplicate_warning++;
369 my $number_of_items = scalar @{$self->assembly_items};
370 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
372 foreach my $item (@{$item_objects}) {
373 my $errstr = validate_assembly($item->part,$self->part);
374 return $self->js->flash('error',$errstr)->render if $errstr;
379 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
381 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
383 push(@{$self->assembly_items}, @{$item_objects});
384 my $part = SL::DB::Part->new(part_type => 'assembly');
385 $part->assemblies(@{$self->assembly_items});
386 my $items_sellprice_sum = $part->items_sellprice_sum;
387 my $items_lastcost_sum = $part->items_lastcost_sum;
388 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
391 ->append('#assembly_rows', $html) # append in tbody
392 ->val('.add_assembly_item_input' , '')
393 ->run('kivi.Part.focus_last_assembly_input')
394 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
395 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
396 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
397 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
398 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
402 sub action_show_multi_items_dialog {
403 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
404 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
408 sub action_multi_items_update_result {
411 $::form->{multi_items}->{filter}->{obsolete} = 0;
413 my $count = $_[0]->multi_items_models->count;
416 my $text = escape($::locale->text('No results.'));
417 $_[0]->render($text, { layout => 0 });
418 } elsif ($count > $max_count) {
419 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
420 $_[0]->render($text, { layout => 0 });
422 my $multi_items = $_[0]->multi_items_models->get;
423 $_[0]->render('part/_multi_items_result', { layout => 0 },
424 multi_items => $multi_items);
428 sub action_add_makemodel_row {
431 my $vendor_id = $::form->{add_makemodel};
433 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
434 return $self->js->error(t8("No vendor selected or found!"))->render;
436 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
437 $self->js->flash('info', t8("This vendor has already been added."));
440 my $position = scalar @{$self->makemodels} + 1;
442 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
446 sortorder => $position,
447 ) or die "Can't create MakeModel object";
449 my $row_as_html = $self->p->render('part/_makemodel_row',
451 listrow => $position % 2 ? 0 : 1,
454 # after selection focus on the model field in the row that was just added
456 ->append('#makemodel_rows', $row_as_html) # append in tbody
457 ->val('.add_makemodel_input', '')
458 ->run('kivi.Part.focus_last_makemodel_input')
462 sub action_add_customerprice_row {
465 my $customer_id = $::form->{add_customerprice};
467 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
468 or return $self->js->error(t8("No customer selected or found!"))->render;
470 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
471 $self->js->flash('info', t8("This customer has already been added."));
474 my $position = scalar @{ $self->customerprices } + 1;
476 my $cu = SL::DB::PartCustomerPrice->new(
477 customer_id => $customer->id,
478 customer_partnumber => '',
480 sortorder => $position,
481 ) or die "Can't create Customerprice object";
483 my $row_as_html = $self->p->render(
484 'part/_customerprice_row',
485 customerprice => $cu,
486 listrow => $position % 2 ? 0
490 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
491 ->val('.add_customerprice_input', '')
492 ->run('kivi.Part.focus_last_customerprice_input')->render;
495 sub action_reorder_items {
498 my $part_type = $::form->{part_type};
501 partnumber => sub { $_[0]->part->partnumber },
502 description => sub { $_[0]->part->description },
503 qty => sub { $_[0]->qty },
504 sellprice => sub { $_[0]->part->sellprice },
505 lastcost => sub { $_[0]->part->lastcost },
506 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
509 my $method = $sort_keys{$::form->{order_by}};
512 if ($part_type eq 'assortment') {
513 @items = @{ $self->assortment_items };
515 @items = @{ $self->assembly_items };
518 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
519 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
520 if ($::form->{sort_dir}) {
521 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
523 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
526 if ($::form->{sort_dir}) {
527 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
529 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
533 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
536 sub action_warehouse_changed {
539 if ($::form->{warehouse_id} ) {
540 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
541 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
543 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
544 $self->bin($self->warehouse->bins->[0]);
546 ->html('#bin', $self->build_bin_select)
547 ->focus('#part_bin_id');
548 return $self->js->render;
552 # no warehouse was selected, empty the bin field and reset the id
554 ->val('#part_bin_id', undef)
557 return $self->js->render;
560 sub action_ajax_autocomplete {
561 my ($self, %params) = @_;
563 # if someone types something, and hits enter, assume he entered the full name.
564 # if something matches, treat that as sole match
565 # since we need a second get models instance with different filters for that,
566 # we only modify the original filter temporarily in place
567 if ($::form->{prefer_exact}) {
568 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
570 my $exact_models = SL::Controller::Helper::GetModels->new(
573 paginated => { per_page => 2 },
574 with_objects => [ qw(unit_obj classification) ],
577 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
578 $self->parts($exact_matches);
584 value => $_->displayable_name,
585 label => $_->displayable_name,
587 partnumber => $_->partnumber,
588 description => $_->description,
590 part_type => $_->part_type,
592 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
594 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
596 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
599 sub action_test_page {
600 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
603 sub action_part_picker_search {
604 $_[0]->render('part/part_picker_search', { layout => 0 });
607 sub action_part_picker_result {
608 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
614 if ($::request->type eq 'json') {
619 $part_hash = $self->part->as_tree;
620 $part_hash->{cvars} = $self->part->cvar_as_hashref;
623 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
628 sub validate_add_items {
629 scalar @{$::form->{add_items}};
632 sub prepare_assortment_render_vars {
635 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
636 items_lastcost_sum => $self->part->items_lastcost_sum,
637 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
639 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
644 sub prepare_assembly_render_vars {
647 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
648 items_lastcost_sum => $self->part->items_lastcost_sum,
649 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
651 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
659 check_has_valid_part_type($self->part->part_type);
661 $self->_set_javascript;
662 $self->_setup_form_action_bar;
664 my %title_hash = ( part => t8('Add Part'),
665 assembly => t8('Add Assembly'),
666 service => t8('Add Service'),
667 assortment => t8('Add Assortment'),
672 title => $title_hash{$self->part->part_type},
677 sub _set_javascript {
679 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
680 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
683 sub recalc_item_totals {
684 my ($self, %params) = @_;
686 if ( $params{part_type} eq 'assortment' ) {
687 return 0 unless scalar @{$self->assortment_items};
688 } elsif ( $params{part_type} eq 'assembly' ) {
689 return 0 unless scalar @{$self->assembly_items};
691 carp "can only calculate sum for assortments and assemblies";
694 my $part = SL::DB::Part->new(part_type => $params{part_type});
695 if ( $part->is_assortment ) {
696 $part->assortment_items( @{$self->assortment_items} );
697 if ( $params{price_type} eq 'lastcost' ) {
698 return $part->items_lastcost_sum;
700 if ( $params{pricegroup_id} ) {
701 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
703 return $part->items_sellprice_sum;
706 } elsif ( $part->is_assembly ) {
707 $part->assemblies( @{$self->assembly_items} );
708 if ( $params{price_type} eq 'lastcost' ) {
709 return $part->items_lastcost_sum;
711 return $part->items_sellprice_sum;
716 sub check_part_not_modified {
719 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
726 my $is_new = !$self->part->id;
728 my $params = delete($::form->{part}) || { };
730 delete $params->{id};
731 $self->part->assign_attributes(%{ $params});
732 $self->part->bin_id(undef) unless $self->part->warehouse_id;
734 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
735 # will be the case for used assortments when saving, or when a used assortment
737 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
738 $self->part->assortment_items([]);
739 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
742 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
743 $self->part->assemblies([]); # completely rewrite assortments each time
744 $self->part->add_assemblies( @{ $self->assembly_items } );
747 $self->part->translations([]);
748 $self->parse_form_translations;
750 $self->part->prices([]);
751 $self->parse_form_prices;
753 $self->parse_form_customerprices;
754 $self->parse_form_makemodels;
757 sub parse_form_prices {
759 # only save prices > 0
760 my $prices = delete($::form->{prices}) || [];
761 foreach my $price ( @{$prices} ) {
762 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
763 next unless $sellprice > 0; # skip negative prices as well
764 my $p = SL::DB::Price->new(parts_id => $self->part->id,
765 pricegroup_id => $price->{pricegroup_id},
768 $self->part->add_prices($p);
772 sub parse_form_translations {
774 # don't add empty translations
775 my $translations = delete($::form->{translations}) || [];
776 foreach my $translation ( @{$translations} ) {
777 next unless $translation->{translation};
778 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
779 $self->part->add_translations( $translation );
783 sub parse_form_makemodels {
787 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
788 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
791 $self->part->makemodels([]);
794 my $makemodels = delete($::form->{makemodels}) || [];
795 foreach my $makemodel ( @{$makemodels} ) {
796 next unless $makemodel->{make};
798 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
800 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
801 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
803 make => $makemodel->{make},
804 model => $makemodel->{model} || '',
805 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
806 sortorder => $position,
808 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
809 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
810 # don't change lastupdate
811 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
812 # new makemodel, no lastcost entered, leave lastupdate empty
813 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
814 # lastcost hasn't changed, use original lastupdate
815 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
817 $mm->lastupdate(DateTime->now);
819 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
820 $self->part->add_makemodels($mm);
824 sub parse_form_customerprices {
827 my $customerprices_map;
828 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
829 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
832 $self->part->customerprices([]);
835 my $customerprices = delete($::form->{customerprices}) || [];
836 foreach my $customerprice ( @{$customerprices} ) {
837 next unless $customerprice->{customer_id};
839 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
841 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
842 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
844 customer_id => $customerprice->{customer_id},
845 customer_partnumber => $customerprice->{customer_partnumber} || '',
846 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
847 sortorder => $position,
849 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
850 # lastupdate isn't set, original price is 0 and new lastcost is 0
851 # don't change lastupdate
852 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
853 # new customerprice, no lastcost entered, leave lastupdate empty
854 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
855 # price hasn't changed, use original lastupdate
856 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
858 $cu->lastupdate(DateTime->now);
860 $self->part->add_customerprices($cu);
864 sub build_bin_select {
865 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
866 title_key => 'description',
867 default => $_[0]->bin->id,
872 # get_set_inits for partpicker
875 if ($::form->{no_paginate}) {
876 $_[0]->models->disable_plugin('paginated');
882 # get_set_inits for part controller
886 # used by edit, save, delete and add
888 if ( $::form->{part}{id} ) {
889 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
891 die "part_type missing" unless $::form->{part}{part_type};
892 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
898 return $self->part->orphaned;
904 SL::Controller::Helper::GetModels->new(
911 partnumber => t8('Partnumber'),
912 description => t8('Description'),
914 with_objects => [ qw(unit_obj classification) ],
923 sub init_assortment_items {
924 # this init is used while saving and whenever assortments change dynamically
928 my $assortment_items = delete($::form->{assortment_items}) || [];
929 foreach my $assortment_item ( @{$assortment_items} ) {
930 next unless $assortment_item->{parts_id};
932 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
933 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
934 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
935 charge => $assortment_item->{charge},
936 unit => $assortment_item->{unit} || $part->unit,
937 position => $position,
945 sub init_makemodels {
949 my @makemodel_array = ();
950 my $makemodels = delete($::form->{makemodels}) || [];
952 foreach my $makemodel ( @{$makemodels} ) {
953 next unless $makemodel->{make};
955 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
956 id => $makemodel->{id},
957 make => $makemodel->{make},
958 model => $makemodel->{model} || '',
959 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
960 sortorder => $position,
961 ) or die "Can't create mm";
962 # $mm->id($makemodel->{id}) if $makemodel->{id};
963 push(@makemodel_array, $mm);
965 return \@makemodel_array;
968 sub init_customerprices {
972 my @customerprice_array = ();
973 my $customerprices = delete($::form->{customerprices}) || [];
975 foreach my $customerprice ( @{$customerprices} ) {
976 next unless $customerprice->{customer_id};
978 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
979 id => $customerprice->{id},
980 customer_partnumber => $customerprice->{customer_partnumber},
981 customer_id => $customerprice->{customer_id} || '',
982 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
983 sortorder => $position,
984 ) or die "Can't create cu";
985 # $cu->id($customerprice->{id}) if $customerprice->{id};
986 push(@customerprice_array, $cu);
988 return \@customerprice_array;
991 sub init_assembly_items {
995 my $assembly_items = delete($::form->{assembly_items}) || [];
996 foreach my $assembly_item ( @{$assembly_items} ) {
997 next unless $assembly_item->{parts_id};
999 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1000 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1001 bom => $assembly_item->{bom},
1002 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1003 position => $position,
1010 sub init_all_warehouses {
1012 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1015 sub init_all_languages {
1016 SL::DB::Manager::Language->get_all_sorted;
1019 sub init_all_partsgroups {
1021 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1024 sub init_all_buchungsgruppen {
1026 if ( $self->part->orphaned ) {
1027 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1029 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1033 sub init_shops_not_assigned {
1036 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1037 if ( @used_shop_ids ) {
1038 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1041 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1045 sub init_all_units {
1047 if ( $self->part->orphaned ) {
1048 return SL::DB::Manager::Unit->get_all_sorted;
1050 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1054 sub init_all_payment_terms {
1056 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1059 sub init_all_price_factors {
1060 SL::DB::Manager::PriceFactor->get_all_sorted;
1063 sub init_all_pricegroups {
1064 SL::DB::Manager::Pricegroup->get_all_sorted;
1067 # model used to filter/display the parts in the multi-items dialog
1068 sub init_multi_items_models {
1069 SL::Controller::Helper::GetModels->new(
1070 controller => $_[0],
1072 with_objects => [ qw(unit_obj partsgroup classification) ],
1073 disable_plugin => 'paginated',
1074 source => $::form->{multi_items},
1080 partnumber => t8('Partnumber'),
1081 description => t8('Description')}
1085 sub init_parts_classification_filter {
1086 return [] unless $::form->{parts_classification_type};
1088 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1089 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1091 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1094 # simple checks to run on $::form before saving
1096 sub form_check_part_description_exists {
1099 return 1 if $::form->{part}{description};
1101 $self->js->flash('error', t8('Part Description missing!'))
1102 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1103 ->focus('#part_description');
1107 sub form_check_assortment_items_exist {
1110 return 1 unless $::form->{part}{part_type} eq 'assortment';
1111 # skip item check for existing assortments that have been used
1112 return 1 if ($self->part->id and !$self->part->orphaned);
1114 # new or orphaned parts must have items in $::form->{assortment_items}
1115 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1116 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1117 ->focus('#add_assortment_item_name')
1118 ->flash('error', t8('The assortment doesn\'t have any items.'));
1124 sub form_check_assortment_items_unique {
1127 return 1 unless $::form->{part}{part_type} eq 'assortment';
1129 my %duplicate_elements;
1131 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1132 $duplicate_elements{$_}++ if $count{$_}++;
1135 if ( keys %duplicate_elements ) {
1136 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1137 ->flash('error', t8('There are duplicate assortment items'));
1143 sub form_check_assembly_items_exist {
1146 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1148 # skip item check for existing assembly that have been used
1149 return 1 if ($self->part->id and !$self->part->orphaned);
1151 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1152 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1153 ->focus('#add_assembly_item_name')
1154 ->flash('error', t8('The assembly doesn\'t have any items.'));
1160 sub form_check_partnumber_is_unique {
1163 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1164 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1166 $self->js->flash('error', t8('The partnumber already exists!'))
1167 ->focus('#part_description');
1174 # general checking functions
1177 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1183 $self->form_check_part_description_exists || return 0;
1184 $self->form_check_assortment_items_exist || return 0;
1185 $self->form_check_assortment_items_unique || return 0;
1186 $self->form_check_assembly_items_exist || return 0;
1187 $self->form_check_partnumber_is_unique || return 0;
1192 sub check_has_valid_part_type {
1193 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1196 sub render_assortment_items_to_html {
1197 my ($self, $assortment_items, $number_of_items) = @_;
1199 my $position = $number_of_items + 1;
1201 foreach my $ai (@$assortment_items) {
1202 $html .= $self->p->render('part/_assortment_row',
1203 PART => $self->part,
1204 orphaned => $self->orphaned,
1206 listrow => $position % 2 ? 1 : 0,
1207 position => $position, # for legacy assemblies
1214 sub render_assembly_items_to_html {
1215 my ($self, $assembly_items, $number_of_items) = @_;
1217 my $position = $number_of_items + 1;
1219 foreach my $ai (@{$assembly_items}) {
1220 $html .= $self->p->render('part/_assembly_row',
1221 PART => $self->part,
1222 orphaned => $self->orphaned,
1224 listrow => $position % 2 ? 1 : 0,
1225 position => $position, # for legacy assemblies
1232 sub parse_add_items_to_objects {
1233 my ($self, %params) = @_;
1234 my $part_type = $params{part_type};
1235 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1236 my $position = $params{position} || 1;
1238 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1241 foreach my $item ( @add_items ) {
1242 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1244 if ( $part_type eq 'assortment' ) {
1245 $ai = SL::DB::AssortmentItem->new(part => $part,
1246 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1247 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1248 position => $position,
1249 ) or die "Can't create AssortmentItem from item";
1250 } elsif ( $part_type eq 'assembly' ) {
1251 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1252 # id => $self->assembly->id, # will be set on save
1253 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1254 bom => 0, # default when adding: no bom
1255 position => $position,
1258 die "part_type must be assortment or assembly";
1260 push(@item_objects, $ai);
1264 return \@item_objects;
1267 sub _setup_form_action_bar {
1270 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1271 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1273 for my $bar ($::request->layout->get('actionbar')) {
1278 call => [ 'kivi.Part.save' ],
1279 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1283 call => [ 'kivi.Part.use_as_new' ],
1284 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1285 : !$may_edit ? t8('You do not have the permissions to access this function.')
1288 ], # end of combobox "Save"
1292 call => [ 'kivi.Part.delete' ],
1293 confirm => t8('Do you really want to delete this object?'),
1294 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1295 : !$may_edit ? t8('You do not have the permissions to access this function.')
1296 : !$self->part->orphaned ? t8('This object has already been used.')
1297 : $used_in_pricerules ? t8('This object is used in price rules.')
1305 call => [ 'kivi.Part.open_history_popup' ],
1306 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1307 : !$may_edit ? t8('You do not have the permissions to access this function.')
1322 SL::Controller::Part - Part CRUD controller
1326 Controller for adding/editing/saving/deleting parts.
1328 All the relations are loaded at once and saving the part, adding a history
1329 entry and saving CVars happens inside one transaction. When saving the old
1330 relations are deleted and written as new to the database.
1332 Relations for parts:
1340 =item assembly items
1342 =item assortment items
1350 There are 4 different part types:
1356 The "default" part type.
1358 inventory_accno_id is set.
1362 Services can't be stocked.
1364 inventory_accno_id isn't set.
1368 Assemblies consist of other parts, services, assemblies or assortments. They
1369 aren't meant to be bought, only sold. To add assemblies to stock you typically
1370 have to make them, which reduces the stock by its respective components. Once
1371 an assembly item has been created there is currently no way to "disassemble" it
1372 again. An assembly item can appear several times in one assembly. An assmbly is
1373 sold as one item with a defined sellprice and lastcost. If the component prices
1374 change the assortment price remains the same. The assembly items may be printed
1375 in a record if the item's "bom" is set.
1379 Similar to assembly, but each assortment item may only appear once per
1380 assortment. When selling an assortment the assortment items are added to the
1381 record together with the assortment, which is added with sellprice 0.
1383 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1384 determined by the sum of the current assortment item prices when the assortment
1385 is added to a record. This also means that price rules and customer discounts
1386 will be applied to the assortment items.
1388 Once the assortment items have been added they may be modified or deleted, just
1389 as if they had been added manually, the individual assortment items aren't
1390 linked to the assortment or the other assortment items in any way.
1398 =item C<action_add_part>
1400 =item C<action_add_service>
1402 =item C<action_add_assembly>
1404 =item C<action_add_assortment>
1406 =item C<action_add PART_TYPE>
1408 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1409 parameter part_type as an action. Example:
1411 controller.pl?action=Part/add&part_type=service
1413 =item C<action_add_from_record>
1415 When adding new items to records they can be created on the fly if the entered
1416 partnumber or description doesn't exist yet. After being asked what part type
1417 the new item should have the user is redirected to the correct edit page.
1419 Depending on whether the item was added from a sales or a purchase record, only
1420 the relevant part classifications should be selectable for new item, so this
1421 parameter is passed on via a hidden parts_classification_type in the new_item
1424 =item C<action_save>
1426 Saves the current part and then reloads the edit page for the part.
1428 =item C<action_use_as_new>
1430 Takes the information from the current part, plus any modifications made on the
1431 page, and creates a new edit page that is ready to be saved. The partnumber is
1432 set empty, so a new partnumber from the number range will be used if the user
1433 doesn't enter one manually.
1435 Unsaved changes to the original part aren't updated.
1437 The part type cannot be changed in this way.
1439 =item C<action_delete>
1441 Deletes the current part and then redirects to the main page, there is no
1444 The delete button only appears if the part is 'orphaned', according to
1445 SL::DB::Part orphaned.
1447 The part can't be deleted if it appears in invoices, orders, delivery orders,
1448 the inventory, or is part of an assembly or assortment.
1450 If the part is deleted its relations prices, makdemodel, assembly,
1451 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1453 Before this controller items that appeared in inventory didn't count as
1454 orphaned and could be deleted and the inventory entries were also deleted, this
1455 "feature" hasn't been implemented.
1457 =item C<action_edit part.id>
1459 Load and display a part for editing.
1461 controller.pl?action=Part/edit&part.id=12345
1463 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1467 =head1 BUTTON ACTIONS
1473 Opens a popup displaying all the history entries. Once a new history controller
1474 is written the button could link there instead, with the part already selected.
1482 =item C<action_update_item_totals>
1484 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1485 amount of an item changes. The sum of all sellprices and lastcosts is
1486 calculated and the totals updated. Uses C<recalc_item_totals>.
1488 =item C<action_add_assortment_item>
1490 Adds a new assortment item from a part picker seleciton to the assortment item list
1492 If the item already exists in the assortment the item isn't added and a Flash
1495 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1496 after adding each new item, add the new object to the item objects that were
1497 already parsed, calculate totals via a dummy part then update the row and the
1500 =item C<action_add_assembly_item>
1502 Adds a new assembly item from a part picker seleciton to the assembly item list
1504 If the item already exists in the assembly a flash info is generated, but the
1507 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1508 after adding each new item, add the new object to the item objects that were
1509 already parsed, calculate totals via a dummy part then update the row and the
1512 =item C<action_add_multi_assortment_items>
1514 Parses the items to be added from the form generated by the multi input and
1515 appends the html of the tr-rows to the assortment item table. Afterwards all
1516 assortment items are renumbered and the sums recalculated via
1517 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1519 =item C<action_add_multi_assembly_items>
1521 Parses the items to be added from the form generated by the multi input and
1522 appends the html of the tr-rows to the assembly item table. Afterwards all
1523 assembly items are renumbered and the sums recalculated via
1524 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1526 =item C<action_show_multi_items_dialog>
1528 =item C<action_multi_items_update_result>
1530 =item C<action_add_makemodel_row>
1532 Add a new makemodel row with the vendor that was selected via the vendor
1535 Checks the already existing makemodels and warns if a row with that vendor
1536 already exists. Currently it is possible to have duplicate vendor rows.
1538 =item C<action_reorder_items>
1540 Sorts the item table for assembly or assortment items.
1542 =item C<action_warehouse_changed>
1546 =head1 ACTIONS part picker
1550 =item C<action_ajax_autocomplete>
1552 =item C<action_test_page>
1554 =item C<action_part_picker_search>
1556 =item C<action_part_picker_result>
1558 =item C<action_show>
1568 Calls some simple checks that test the submitted $::form for obvious errors.
1569 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1571 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1572 some cases extra actions are taken, e.g. if the part description is missing the
1573 basic data tab is selected and the description input field is focussed.
1579 =item C<form_check_part_description_exists>
1581 =item C<form_check_assortment_items_exist>
1583 =item C<form_check_assortment_items_unique>
1585 =item C<form_check_assembly_items_exist>
1587 =item C<form_check_partnumber_is_unique>
1591 =head1 HELPER FUNCTIONS
1597 When submitting the form for saving, parses the transmitted form. Expects the
1601 $::form->{makemodels}
1602 $::form->{translations}
1604 $::form->{assemblies}
1605 $::form->{assortments}
1607 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1609 =item C<recalc_item_totals %params>
1611 Helper function for calculating the total lastcost and sellprice for assemblies
1612 or assortments according to their items, which are parsed from the current
1615 Is called whenever the qty of an item is changed or items are deleted.
1619 * part_type : 'assortment' or 'assembly' (mandatory)
1621 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1623 Depending on the price_type the lastcost sum or sellprice sum is returned.
1625 Doesn't work for recursive items.
1629 =head1 GET SET INITS
1631 There are get_set_inits for
1639 which parse $::form and automatically create an array of objects.
1641 These inits are used during saving and each time a new element is added.
1645 =item C<init_makemodels>
1647 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1648 $self->part->makemodels, ready to be saved.
1650 Used for saving parts and adding new makemodel rows.
1652 =item C<parse_add_items_to_objects PART_TYPE>
1654 Parses the resulting form from either the part-picker submit or the multi-item
1655 submit, and creates an arrayref of assortment_item or assembly objects, that
1656 can be rendered via C<render_assortment_items_to_html> or
1657 C<render_assembly_items_to_html>.
1659 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1660 Optional param: position (used for numbering and listrow class)
1662 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1664 Takes an array_ref of assortment_items, and generates tables rows ready for
1665 adding to the assortment table. Is used when a part is loaded, or whenever new
1666 assortment items are added.
1668 =item C<parse_form_makemodels>
1670 Makemodels can't just be overwritten, because of the field "lastupdate", that
1671 remembers when the lastcost for that vendor changed the last time.
1673 So the original values are cloned and remembered, so we can compare if lastcost
1674 was changed in $::form, and keep or update lastupdate.
1676 lastcost isn't updated until the first time it was saved with a value, until
1679 Also a boolean "makemodel" needs to be written in parts, depending on whether
1680 makemodel entries exist or not.
1682 We still need init_makemodels for when we open the part for editing.
1692 It should be possible to jump to the edit page in a specific tab
1696 Support callbacks, e.g. creating a new part from within an order, and jumping
1697 back to the order again afterwards.
1701 Support units when adding assembly items or assortment items. Currently the
1702 default unit of the item is always used.
1706 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1707 consists of other assemblies.
1713 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>