1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
10 use SL::Controller::Helper::GetModels;
11 use SL::Locale::String qw(t8);
13 use List::Util qw(sum);
14 use SL::Helper::Flash;
18 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
20 use SL::MoreCommon qw(save_form);
22 use SL::Presenter::EscapedText qw(escape is_escaped);
23 use SL::Presenter::Tag qw(select_tag);
25 use Rose::Object::MakeMethods::Generic (
26 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
27 makemodels shops_not_assigned
30 assortment assortment_items assembly assembly_items
31 all_pricegroups all_translations all_partsgroups all_units
32 all_buchungsgruppen all_payment_terms all_warehouses
33 parts_classification_filter
34 all_languages all_units all_price_factors) ],
35 'scalar' => [ qw(warehouse bin) ],
39 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
40 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
42 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
44 # actions for editing parts
47 my ($self, %params) = @_;
49 $self->part( SL::DB::Part->new_part );
53 sub action_add_service {
54 my ($self, %params) = @_;
56 $self->part( SL::DB::Part->new_service );
60 sub action_add_assembly {
61 my ($self, %params) = @_;
63 $self->part( SL::DB::Part->new_assembly );
67 sub action_add_assortment {
68 my ($self, %params) = @_;
70 $self->part( SL::DB::Part->new_assortment );
74 sub action_add_from_record {
77 check_has_valid_part_type($::form->{part}{part_type});
79 die 'parts_classification_type must be "sales" or "purchases"'
80 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
89 check_has_valid_part_type($::form->{part_type});
91 $self->action_add_part if $::form->{part_type} eq 'part';
92 $self->action_add_service if $::form->{part_type} eq 'service';
93 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
94 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
98 my ($self, %params) = @_;
100 # checks that depend only on submitted $::form
101 $self->check_form or return $self->js->render;
103 my $is_new = !$self->part->id; # $ part gets loaded here
105 # check that the part hasn't been modified
107 $self->check_part_not_modified or
108 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;
112 && $::form->{part}{partnumber}
113 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
115 return $self->js->error(t8('The partnumber is already being used'))->render;
120 my @errors = $self->part->validate;
121 return $self->js->error(@errors)->render if @errors;
123 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
124 $self->part->db->with_transaction(sub {
126 if ( $params{save_as_new} ) {
127 $self->part( $self->part->clone_and_reset_deep );
128 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
131 $self->part->save(cascade => 1);
133 SL::DB::History->new(
134 trans_id => $self->part->id,
135 snumbers => 'partnumber_' . $self->part->partnumber,
136 employee_id => SL::DB::Manager::Employee->current->id,
141 CVar->save_custom_variables(
142 dbh => $self->part->db->dbh,
144 trans_id => $self->part->id,
145 variables => $::form, # $::form->{cvar} would be nicer
150 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
153 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
155 if ( $::form->{callback} ) {
156 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
159 # default behaviour after save: reload item, this also resets last_modification!
160 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
164 sub action_save_as_new {
166 $self->action_save(save_as_new=>1);
172 my $db = $self->part->db; # $self->part has a get_set_init on $::form
174 my $partnumber = $self->part->partnumber; # remember for history log
179 # delete part, together with relationships that don't already
180 # have an ON DELETE CASCADE, e.g. makemodel and translation.
181 $self->part->delete(cascade => 1);
183 SL::DB::History->new(
184 trans_id => $self->part->id,
185 snumbers => 'partnumber_' . $partnumber,
186 employee_id => SL::DB::Manager::Employee->current->id,
188 addition => 'DELETED',
191 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
193 flash_later('info', t8('The item has been deleted.'));
194 if ( $::form->{callback} ) {
195 $self->redirect_to($::form->unescape($::form->{callback}));
197 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
201 sub action_use_as_new {
202 my ($self, %params) = @_;
204 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
205 $::form->{oldpartnumber} = $oldpart->partnumber;
207 $self->part($oldpart->clone_and_reset_deep);
209 $self->part->partnumber(undef);
215 my ($self, %params) = @_;
221 my ($self, %params) = @_;
223 $self->_set_javascript;
224 $self->_setup_form_action_bar;
226 my (%assortment_vars, %assembly_vars);
227 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
228 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
230 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
232 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
233 if (scalar @{ $params{CUSTOM_VARIABLES} });
235 my %title_hash = ( part => t8('Edit Part'),
236 assembly => t8('Edit Assembly'),
237 service => t8('Edit Service'),
238 assortment => t8('Edit Assortment'),
241 $self->part->prices([]) unless $self->part->prices;
242 $self->part->translations([]) unless $self->part->translations;
246 title => $title_hash{$self->part->part_type},
249 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
250 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
251 oldpartnumber => $::form->{oldpartnumber},
252 old_id => $::form->{old_id},
260 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
261 $_[0]->render('part/history', { layout => 0 },
262 history_entries => $history_entries);
265 sub action_update_item_totals {
268 my $part_type = $::form->{part_type};
269 die unless $part_type =~ /^(assortment|assembly)$/;
271 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
272 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
274 my $sum_diff = $sellprice_sum-$lastcost_sum;
277 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
278 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
279 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
280 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
281 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
282 ->no_flash_clear->render();
285 sub action_add_multi_assortment_items {
288 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
289 my $html = $self->render_assortment_items_to_html($item_objects);
291 $self->js->run('kivi.Part.close_picker_dialogs')
292 ->append('#assortment_rows', $html)
293 ->run('kivi.Part.renumber_positions')
294 ->run('kivi.Part.assortment_recalc')
298 sub action_add_multi_assembly_items {
301 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
303 foreach my $item (@{$item_objects}) {
304 my $errstr = validate_assembly($item->part,$self->part);
305 $self->js->flash('error',$errstr) if $errstr;
306 push (@checked_objects,$item) unless $errstr;
309 my $html = $self->render_assembly_items_to_html(\@checked_objects);
311 $self->js->run('kivi.Part.close_picker_dialogs')
312 ->append('#assembly_rows', $html)
313 ->run('kivi.Part.renumber_positions')
314 ->run('kivi.Part.assembly_recalc')
318 sub action_add_assortment_item {
319 my ($self, %params) = @_;
321 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
323 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
325 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
326 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
327 return $self->js->flash('error', t8("This part has already been added."))->render;
330 my $number_of_items = scalar @{$self->assortment_items};
331 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
332 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
334 push(@{$self->assortment_items}, @{$item_objects});
335 my $part = SL::DB::Part->new(part_type => 'assortment');
336 $part->assortment_items(@{$self->assortment_items});
337 my $items_sellprice_sum = $part->items_sellprice_sum;
338 my $items_lastcost_sum = $part->items_lastcost_sum;
339 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
342 ->append('#assortment_rows' , $html) # append in tbody
343 ->val('.add_assortment_item_input' , '')
344 ->run('kivi.Part.focus_last_assortment_input')
345 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
346 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
347 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
348 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
349 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
353 sub action_add_assembly_item {
356 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
358 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
360 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
362 my $duplicate_warning = 0; # duplicates are allowed, just warn
363 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
364 $duplicate_warning++;
367 my $number_of_items = scalar @{$self->assembly_items};
368 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
370 foreach my $item (@{$item_objects}) {
371 my $errstr = validate_assembly($item->part,$self->part);
372 return $self->js->flash('error',$errstr)->render if $errstr;
377 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
379 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
381 push(@{$self->assembly_items}, @{$item_objects});
382 my $part = SL::DB::Part->new(part_type => 'assembly');
383 $part->assemblies(@{$self->assembly_items});
384 my $items_sellprice_sum = $part->items_sellprice_sum;
385 my $items_lastcost_sum = $part->items_lastcost_sum;
386 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
389 ->append('#assembly_rows', $html) # append in tbody
390 ->val('.add_assembly_item_input' , '')
391 ->run('kivi.Part.focus_last_assembly_input')
392 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
393 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
394 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
395 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
396 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
400 sub action_show_multi_items_dialog {
401 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
402 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
406 sub action_multi_items_update_result {
409 $::form->{multi_items}->{filter}->{obsolete} = 0;
411 my $count = $_[0]->multi_items_models->count;
414 my $text = escape($::locale->text('No results.'));
415 $_[0]->render($text, { layout => 0 });
416 } elsif ($count > $max_count) {
417 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
418 $_[0]->render($text, { layout => 0 });
420 my $multi_items = $_[0]->multi_items_models->get;
421 $_[0]->render('part/_multi_items_result', { layout => 0 },
422 multi_items => $multi_items);
426 sub action_add_makemodel_row {
429 my $vendor_id = $::form->{add_makemodel};
431 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
432 return $self->js->error(t8("No vendor selected or found!"))->render;
434 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
435 $self->js->flash('info', t8("This vendor has already been added."));
438 my $position = scalar @{$self->makemodels} + 1;
440 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
444 sortorder => $position,
445 ) or die "Can't create MakeModel object";
447 my $row_as_html = $self->p->render('part/_makemodel_row',
449 listrow => $position % 2 ? 0 : 1,
452 # after selection focus on the model field in the row that was just added
454 ->append('#makemodel_rows', $row_as_html) # append in tbody
455 ->val('.add_makemodel_input', '')
456 ->run('kivi.Part.focus_last_makemodel_input')
460 sub action_add_customerprice_row {
463 my $customer_id = $::form->{add_customerprice};
465 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
466 or return $self->js->error(t8("No customer selected or found!"))->render;
468 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
469 $self->js->flash('info', t8("This customer has already been added."));
472 my $position = scalar @{ $self->customerprices } + 1;
474 my $cu = SL::DB::PartCustomerPrice->new(
475 customer_id => $customer->id,
476 customer_partnumber => '',
478 sortorder => $position,
479 ) or die "Can't create Customerprice object";
481 my $row_as_html = $self->p->render(
482 'part/_customerprice_row',
483 customerprice => $cu,
484 listrow => $position % 2 ? 0
488 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
489 ->val('.add_customerprice_input', '')
490 ->run('kivi.Part.focus_last_customerprice_input')->render;
493 sub action_reorder_items {
496 my $part_type = $::form->{part_type};
499 partnumber => sub { $_[0]->part->partnumber },
500 description => sub { $_[0]->part->description },
501 qty => sub { $_[0]->qty },
502 sellprice => sub { $_[0]->part->sellprice },
503 lastcost => sub { $_[0]->part->lastcost },
504 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
507 my $method = $sort_keys{$::form->{order_by}};
510 if ($part_type eq 'assortment') {
511 @items = @{ $self->assortment_items };
513 @items = @{ $self->assembly_items };
516 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
517 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
518 if ($::form->{sort_dir}) {
519 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
521 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
524 if ($::form->{sort_dir}) {
525 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
527 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
531 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
534 sub action_warehouse_changed {
537 if ($::form->{warehouse_id} ) {
538 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
539 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
541 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
542 $self->bin($self->warehouse->bins->[0]);
544 ->html('#bin', $self->build_bin_select)
545 ->focus('#part_bin_id');
546 return $self->js->render;
550 # no warehouse was selected, empty the bin field and reset the id
552 ->val('#part_bin_id', undef)
555 return $self->js->render;
558 sub action_ajax_autocomplete {
559 my ($self, %params) = @_;
561 # if someone types something, and hits enter, assume he entered the full name.
562 # if something matches, treat that as sole match
563 # since we need a second get models instance with different filters for that,
564 # we only modify the original filter temporarily in place
565 if ($::form->{prefer_exact}) {
566 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
568 my $exact_models = SL::Controller::Helper::GetModels->new(
571 paginated => { per_page => 2 },
572 with_objects => [ qw(unit_obj classification) ],
575 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
576 $self->parts($exact_matches);
582 value => $_->displayable_name,
583 label => $_->displayable_name,
585 partnumber => $_->partnumber,
586 description => $_->description,
588 part_type => $_->part_type,
590 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
592 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
594 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
597 sub action_test_page {
598 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
601 sub action_part_picker_search {
602 $_[0]->render('part/part_picker_search', { layout => 0 });
605 sub action_part_picker_result {
606 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
612 if ($::request->type eq 'json') {
617 $part_hash = $self->part->as_tree;
618 $part_hash->{cvars} = $self->part->cvar_as_hashref;
621 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
626 sub validate_add_items {
627 scalar @{$::form->{add_items}};
630 sub prepare_assortment_render_vars {
633 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
634 items_lastcost_sum => $self->part->items_lastcost_sum,
635 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
637 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
642 sub prepare_assembly_render_vars {
645 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
646 items_lastcost_sum => $self->part->items_lastcost_sum,
647 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
649 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
657 check_has_valid_part_type($self->part->part_type);
659 $self->_set_javascript;
660 $self->_setup_form_action_bar;
662 my %title_hash = ( part => t8('Add Part'),
663 assembly => t8('Add Assembly'),
664 service => t8('Add Service'),
665 assortment => t8('Add Assortment'),
670 title => $title_hash{$self->part->part_type},
675 sub _set_javascript {
677 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
678 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
681 sub recalc_item_totals {
682 my ($self, %params) = @_;
684 if ( $params{part_type} eq 'assortment' ) {
685 return 0 unless scalar @{$self->assortment_items};
686 } elsif ( $params{part_type} eq 'assembly' ) {
687 return 0 unless scalar @{$self->assembly_items};
689 carp "can only calculate sum for assortments and assemblies";
692 my $part = SL::DB::Part->new(part_type => $params{part_type});
693 if ( $part->is_assortment ) {
694 $part->assortment_items( @{$self->assortment_items} );
695 if ( $params{price_type} eq 'lastcost' ) {
696 return $part->items_lastcost_sum;
698 if ( $params{pricegroup_id} ) {
699 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
701 return $part->items_sellprice_sum;
704 } elsif ( $part->is_assembly ) {
705 $part->assemblies( @{$self->assembly_items} );
706 if ( $params{price_type} eq 'lastcost' ) {
707 return $part->items_lastcost_sum;
709 return $part->items_sellprice_sum;
714 sub check_part_not_modified {
717 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
724 my $is_new = !$self->part->id;
726 my $params = delete($::form->{part}) || { };
728 delete $params->{id};
729 $self->part->assign_attributes(%{ $params});
730 $self->part->bin_id(undef) unless $self->part->warehouse_id;
732 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
733 # will be the case for used assortments when saving, or when a used assortment
735 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
736 $self->part->assortment_items([]);
737 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
740 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
741 $self->part->assemblies([]); # completely rewrite assortments each time
742 $self->part->add_assemblies( @{ $self->assembly_items } );
745 $self->part->translations([]);
746 $self->parse_form_translations;
748 $self->part->prices([]);
749 $self->parse_form_prices;
751 $self->parse_form_customerprices;
752 $self->parse_form_makemodels;
755 sub parse_form_prices {
757 # only save prices > 0
758 my $prices = delete($::form->{prices}) || [];
759 foreach my $price ( @{$prices} ) {
760 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
761 next unless $sellprice > 0; # skip negative prices as well
762 my $p = SL::DB::Price->new(parts_id => $self->part->id,
763 pricegroup_id => $price->{pricegroup_id},
766 $self->part->add_prices($p);
770 sub parse_form_translations {
772 # don't add empty translations
773 my $translations = delete($::form->{translations}) || [];
774 foreach my $translation ( @{$translations} ) {
775 next unless $translation->{translation};
776 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
777 $self->part->add_translations( $translation );
781 sub parse_form_makemodels {
785 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
786 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
789 $self->part->makemodels([]);
792 my $makemodels = delete($::form->{makemodels}) || [];
793 foreach my $makemodel ( @{$makemodels} ) {
794 next unless $makemodel->{make};
796 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
798 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
799 id => $makemodel->{id},
800 make => $makemodel->{make},
801 model => $makemodel->{model} || '',
802 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
803 sortorder => $position,
805 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
806 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
807 # don't change lastupdate
808 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
809 # new makemodel, no lastcost entered, leave lastupdate empty
810 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
811 # lastcost hasn't changed, use original lastupdate
812 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
814 $mm->lastupdate(DateTime->now);
816 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
817 $self->part->add_makemodels($mm);
821 sub parse_form_customerprices {
824 my $customerprices_map;
825 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
826 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
829 $self->part->customerprices([]);
832 my $customerprices = delete($::form->{customerprices}) || [];
833 foreach my $customerprice ( @{$customerprices} ) {
834 next unless $customerprice->{customer_id};
836 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
838 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
839 id => $customerprice->{id},
840 customer_id => $customerprice->{customer_id},
841 customer_partnumber => $customerprice->{customer_partnumber} || '',
842 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
843 sortorder => $position,
845 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
846 # lastupdate isn't set, original price is 0 and new lastcost is 0
847 # don't change lastupdate
848 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
849 # new customerprice, no lastcost entered, leave lastupdate empty
850 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
851 # price hasn't changed, use original lastupdate
852 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
854 $cu->lastupdate(DateTime->now);
856 $self->part->add_customerprices($cu);
860 sub build_bin_select {
861 select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
862 title_key => 'description',
863 default => $_[0]->bin->id,
868 # get_set_inits for partpicker
871 if ($::form->{no_paginate}) {
872 $_[0]->models->disable_plugin('paginated');
878 # get_set_inits for part controller
882 # used by edit, save, delete and add
884 if ( $::form->{part}{id} ) {
885 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
887 die "part_type missing" unless $::form->{part}{part_type};
888 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
894 return $self->part->orphaned;
900 SL::Controller::Helper::GetModels->new(
907 partnumber => t8('Partnumber'),
908 description => t8('Description'),
910 with_objects => [ qw(unit_obj classification) ],
919 sub init_assortment_items {
920 # this init is used while saving and whenever assortments change dynamically
924 my $assortment_items = delete($::form->{assortment_items}) || [];
925 foreach my $assortment_item ( @{$assortment_items} ) {
926 next unless $assortment_item->{parts_id};
928 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
929 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
930 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
931 charge => $assortment_item->{charge},
932 unit => $assortment_item->{unit} || $part->unit,
933 position => $position,
941 sub init_makemodels {
945 my @makemodel_array = ();
946 my $makemodels = delete($::form->{makemodels}) || [];
948 foreach my $makemodel ( @{$makemodels} ) {
949 next unless $makemodel->{make};
951 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
952 id => $makemodel->{id},
953 make => $makemodel->{make},
954 model => $makemodel->{model} || '',
955 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
956 sortorder => $position,
957 ) or die "Can't create mm";
958 # $mm->id($makemodel->{id}) if $makemodel->{id};
959 push(@makemodel_array, $mm);
961 return \@makemodel_array;
964 sub init_customerprices {
968 my @customerprice_array = ();
969 my $customerprices = delete($::form->{customerprices}) || [];
971 foreach my $customerprice ( @{$customerprices} ) {
972 next unless $customerprice->{customer_id};
974 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
975 id => $customerprice->{id},
976 customer_partnumber => $customerprice->{customer_partnumber},
977 customer_id => $customerprice->{customer_id} || '',
978 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
979 sortorder => $position,
980 ) or die "Can't create cu";
981 # $cu->id($customerprice->{id}) if $customerprice->{id};
982 push(@customerprice_array, $cu);
984 return \@customerprice_array;
987 sub init_assembly_items {
991 my $assembly_items = delete($::form->{assembly_items}) || [];
992 foreach my $assembly_item ( @{$assembly_items} ) {
993 next unless $assembly_item->{parts_id};
995 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
996 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
997 bom => $assembly_item->{bom},
998 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
999 position => $position,
1006 sub init_all_warehouses {
1008 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1011 sub init_all_languages {
1012 SL::DB::Manager::Language->get_all_sorted;
1015 sub init_all_partsgroups {
1017 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1020 sub init_all_buchungsgruppen {
1022 if ( $self->part->orphaned ) {
1023 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1025 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
1029 sub init_shops_not_assigned {
1032 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1033 if ( @used_shop_ids ) {
1034 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1037 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1041 sub init_all_units {
1043 if ( $self->part->orphaned ) {
1044 return SL::DB::Manager::Unit->get_all_sorted;
1046 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1050 sub init_all_payment_terms {
1052 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1055 sub init_all_price_factors {
1056 SL::DB::Manager::PriceFactor->get_all_sorted;
1059 sub init_all_pricegroups {
1060 SL::DB::Manager::Pricegroup->get_all_sorted;
1063 # model used to filter/display the parts in the multi-items dialog
1064 sub init_multi_items_models {
1065 SL::Controller::Helper::GetModels->new(
1066 controller => $_[0],
1068 with_objects => [ qw(unit_obj partsgroup classification) ],
1069 disable_plugin => 'paginated',
1070 source => $::form->{multi_items},
1076 partnumber => t8('Partnumber'),
1077 description => t8('Description')}
1081 sub init_parts_classification_filter {
1082 return [] unless $::form->{parts_classification_type};
1084 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1085 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1087 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1090 # simple checks to run on $::form before saving
1092 sub form_check_part_description_exists {
1095 return 1 if $::form->{part}{description};
1097 $self->js->flash('error', t8('Part Description missing!'))
1098 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1099 ->focus('#part_description');
1103 sub form_check_assortment_items_exist {
1106 return 1 unless $::form->{part}{part_type} eq 'assortment';
1107 # skip item check for existing assortments that have been used
1108 return 1 if ($self->part->id and !$self->part->orphaned);
1110 # new or orphaned parts must have items in $::form->{assortment_items}
1111 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1112 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1113 ->focus('#add_assortment_item_name')
1114 ->flash('error', t8('The assortment doesn\'t have any items.'));
1120 sub form_check_assortment_items_unique {
1123 return 1 unless $::form->{part}{part_type} eq 'assortment';
1125 my %duplicate_elements;
1127 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1128 $duplicate_elements{$_}++ if $count{$_}++;
1131 if ( keys %duplicate_elements ) {
1132 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1133 ->flash('error', t8('There are duplicate assortment items'));
1139 sub form_check_assembly_items_exist {
1142 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1144 # skip item check for existing assembly that have been used
1145 return 1 if ($self->part->id and !$self->part->orphaned);
1147 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1148 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1149 ->focus('#add_assembly_item_name')
1150 ->flash('error', t8('The assembly doesn\'t have any items.'));
1156 sub form_check_partnumber_is_unique {
1159 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1160 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1162 $self->js->flash('error', t8('The partnumber already exists!'))
1163 ->focus('#part_description');
1170 # general checking functions
1173 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1179 $self->form_check_part_description_exists || return 0;
1180 $self->form_check_assortment_items_exist || return 0;
1181 $self->form_check_assortment_items_unique || return 0;
1182 $self->form_check_assembly_items_exist || return 0;
1183 $self->form_check_partnumber_is_unique || return 0;
1188 sub check_has_valid_part_type {
1189 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1192 sub render_assortment_items_to_html {
1193 my ($self, $assortment_items, $number_of_items) = @_;
1195 my $position = $number_of_items + 1;
1197 foreach my $ai (@$assortment_items) {
1198 $html .= $self->p->render('part/_assortment_row',
1199 PART => $self->part,
1200 orphaned => $self->orphaned,
1202 listrow => $position % 2 ? 1 : 0,
1203 position => $position, # for legacy assemblies
1210 sub render_assembly_items_to_html {
1211 my ($self, $assembly_items, $number_of_items) = @_;
1213 my $position = $number_of_items + 1;
1215 foreach my $ai (@{$assembly_items}) {
1216 $html .= $self->p->render('part/_assembly_row',
1217 PART => $self->part,
1218 orphaned => $self->orphaned,
1220 listrow => $position % 2 ? 1 : 0,
1221 position => $position, # for legacy assemblies
1228 sub parse_add_items_to_objects {
1229 my ($self, %params) = @_;
1230 my $part_type = $params{part_type};
1231 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1232 my $position = $params{position} || 1;
1234 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1237 foreach my $item ( @add_items ) {
1238 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1240 if ( $part_type eq 'assortment' ) {
1241 $ai = SL::DB::AssortmentItem->new(part => $part,
1242 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1243 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1244 position => $position,
1245 ) or die "Can't create AssortmentItem from item";
1246 } elsif ( $part_type eq 'assembly' ) {
1247 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1248 # id => $self->assembly->id, # will be set on save
1249 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1250 bom => 0, # default when adding: no bom
1251 position => $position,
1254 die "part_type must be assortment or assembly";
1256 push(@item_objects, $ai);
1260 return \@item_objects;
1263 sub _setup_form_action_bar {
1266 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1268 for my $bar ($::request->layout->get('actionbar')) {
1273 call => [ 'kivi.Part.save' ],
1274 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1278 call => [ 'kivi.Part.use_as_new' ],
1279 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1280 : !$may_edit ? t8('You do not have the permissions to access this function.')
1283 ], # end of combobox "Save"
1287 call => [ 'kivi.Part.delete' ],
1288 confirm => t8('Do you really want to delete this object?'),
1289 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1290 : !$may_edit ? t8('You do not have the permissions to access this function.')
1291 : !$self->part->orphaned ? t8('This object has already been used.')
1299 call => [ 'kivi.Part.open_history_popup' ],
1300 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1301 : !$may_edit ? t8('You do not have the permissions to access this function.')
1316 SL::Controller::Part - Part CRUD controller
1320 Controller for adding/editing/saving/deleting parts.
1322 All the relations are loaded at once and saving the part, adding a history
1323 entry and saving CVars happens inside one transaction. When saving the old
1324 relations are deleted and written as new to the database.
1326 Relations for parts:
1334 =item assembly items
1336 =item assortment items
1344 There are 4 different part types:
1350 The "default" part type.
1352 inventory_accno_id is set.
1356 Services can't be stocked.
1358 inventory_accno_id isn't set.
1362 Assemblies consist of other parts, services, assemblies or assortments. They
1363 aren't meant to be bought, only sold. To add assemblies to stock you typically
1364 have to make them, which reduces the stock by its respective components. Once
1365 an assembly item has been created there is currently no way to "disassemble" it
1366 again. An assembly item can appear several times in one assembly. An assmbly is
1367 sold as one item with a defined sellprice and lastcost. If the component prices
1368 change the assortment price remains the same. The assembly items may be printed
1369 in a record if the item's "bom" is set.
1373 Similar to assembly, but each assortment item may only appear once per
1374 assortment. When selling an assortment the assortment items are added to the
1375 record together with the assortment, which is added with sellprice 0.
1377 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1378 determined by the sum of the current assortment item prices when the assortment
1379 is added to a record. This also means that price rules and customer discounts
1380 will be applied to the assortment items.
1382 Once the assortment items have been added they may be modified or deleted, just
1383 as if they had been added manually, the individual assortment items aren't
1384 linked to the assortment or the other assortment items in any way.
1392 =item C<action_add_part>
1394 =item C<action_add_service>
1396 =item C<action_add_assembly>
1398 =item C<action_add_assortment>
1400 =item C<action_add PART_TYPE>
1402 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1403 parameter part_type as an action. Example:
1405 controller.pl?action=Part/add&part_type=service
1407 =item C<action_add_from_record>
1409 When adding new items to records they can be created on the fly if the entered
1410 partnumber or description doesn't exist yet. After being asked what part type
1411 the new item should have the user is redirected to the correct edit page.
1413 Depending on whether the item was added from a sales or a purchase record, only
1414 the relevant part classifications should be selectable for new item, so this
1415 parameter is passed on via a hidden parts_classification_type in the new_item
1418 =item C<action_save>
1420 Saves the current part and then reloads the edit page for the part.
1422 =item C<action_use_as_new>
1424 Takes the information from the current part, plus any modifications made on the
1425 page, and creates a new edit page that is ready to be saved. The partnumber is
1426 set empty, so a new partnumber from the number range will be used if the user
1427 doesn't enter one manually.
1429 Unsaved changes to the original part aren't updated.
1431 The part type cannot be changed in this way.
1433 =item C<action_delete>
1435 Deletes the current part and then redirects to the main page, there is no
1438 The delete button only appears if the part is 'orphaned', according to
1439 SL::DB::Part orphaned.
1441 The part can't be deleted if it appears in invoices, orders, delivery orders,
1442 the inventory, or is part of an assembly or assortment.
1444 If the part is deleted its relations prices, makdemodel, assembly,
1445 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1447 Before this controller items that appeared in inventory didn't count as
1448 orphaned and could be deleted and the inventory entries were also deleted, this
1449 "feature" hasn't been implemented.
1451 =item C<action_edit part.id>
1453 Load and display a part for editing.
1455 controller.pl?action=Part/edit&part.id=12345
1457 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1461 =head1 BUTTON ACTIONS
1467 Opens a popup displaying all the history entries. Once a new history controller
1468 is written the button could link there instead, with the part already selected.
1476 =item C<action_update_item_totals>
1478 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1479 amount of an item changes. The sum of all sellprices and lastcosts is
1480 calculated and the totals updated. Uses C<recalc_item_totals>.
1482 =item C<action_add_assortment_item>
1484 Adds a new assortment item from a part picker seleciton to the assortment item list
1486 If the item already exists in the assortment the item isn't added and a Flash
1489 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1490 after adding each new item, add the new object to the item objects that were
1491 already parsed, calculate totals via a dummy part then update the row and the
1494 =item C<action_add_assembly_item>
1496 Adds a new assembly item from a part picker seleciton to the assembly item list
1498 If the item already exists in the assembly a flash info is generated, but the
1501 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1502 after adding each new item, add the new object to the item objects that were
1503 already parsed, calculate totals via a dummy part then update the row and the
1506 =item C<action_add_multi_assortment_items>
1508 Parses the items to be added from the form generated by the multi input and
1509 appends the html of the tr-rows to the assortment item table. Afterwards all
1510 assortment items are renumbered and the sums recalculated via
1511 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1513 =item C<action_add_multi_assembly_items>
1515 Parses the items to be added from the form generated by the multi input and
1516 appends the html of the tr-rows to the assembly item table. Afterwards all
1517 assembly items are renumbered and the sums recalculated via
1518 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1520 =item C<action_show_multi_items_dialog>
1522 =item C<action_multi_items_update_result>
1524 =item C<action_add_makemodel_row>
1526 Add a new makemodel row with the vendor that was selected via the vendor
1529 Checks the already existing makemodels and warns if a row with that vendor
1530 already exists. Currently it is possible to have duplicate vendor rows.
1532 =item C<action_reorder_items>
1534 Sorts the item table for assembly or assortment items.
1536 =item C<action_warehouse_changed>
1540 =head1 ACTIONS part picker
1544 =item C<action_ajax_autocomplete>
1546 =item C<action_test_page>
1548 =item C<action_part_picker_search>
1550 =item C<action_part_picker_result>
1552 =item C<action_show>
1562 Calls some simple checks that test the submitted $::form for obvious errors.
1563 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1565 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1566 some cases extra actions are taken, e.g. if the part description is missing the
1567 basic data tab is selected and the description input field is focussed.
1573 =item C<form_check_part_description_exists>
1575 =item C<form_check_assortment_items_exist>
1577 =item C<form_check_assortment_items_unique>
1579 =item C<form_check_assembly_items_exist>
1581 =item C<form_check_partnumber_is_unique>
1585 =head1 HELPER FUNCTIONS
1591 When submitting the form for saving, parses the transmitted form. Expects the
1595 $::form->{makemodels}
1596 $::form->{translations}
1598 $::form->{assemblies}
1599 $::form->{assortments}
1601 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1603 =item C<recalc_item_totals %params>
1605 Helper function for calculating the total lastcost and sellprice for assemblies
1606 or assortments according to their items, which are parsed from the current
1609 Is called whenever the qty of an item is changed or items are deleted.
1613 * part_type : 'assortment' or 'assembly' (mandatory)
1615 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1617 Depending on the price_type the lastcost sum or sellprice sum is returned.
1619 Doesn't work for recursive items.
1623 =head1 GET SET INITS
1625 There are get_set_inits for
1633 which parse $::form and automatically create an array of objects.
1635 These inits are used during saving and each time a new element is added.
1639 =item C<init_makemodels>
1641 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1642 $self->part->makemodels, ready to be saved.
1644 Used for saving parts and adding new makemodel rows.
1646 =item C<parse_add_items_to_objects PART_TYPE>
1648 Parses the resulting form from either the part-picker submit or the multi-item
1649 submit, and creates an arrayref of assortment_item or assembly objects, that
1650 can be rendered via C<render_assortment_items_to_html> or
1651 C<render_assembly_items_to_html>.
1653 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1654 Optional param: position (used for numbering and listrow class)
1656 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1658 Takes an array_ref of assortment_items, and generates tables rows ready for
1659 adding to the assortment table. Is used when a part is loaded, or whenever new
1660 assortment items are added.
1662 =item C<parse_form_makemodels>
1664 Makemodels can't just be overwritten, because of the field "lastupdate", that
1665 remembers when the lastcost for that vendor changed the last time.
1667 So the original values are cloned and remembered, so we can compare if lastcost
1668 was changed in $::form, and keep or update lastupdate.
1670 lastcost isn't updated until the first time it was saved with a value, until
1673 Also a boolean "makemodel" needs to be written in parts, depending on whether
1674 makemodel entries exist or not.
1676 We still need init_makemodels for when we open the part for editing.
1686 It should be possible to jump to the edit page in a specific tab
1690 Support callbacks, e.g. creating a new part from within an order, and jumping
1691 back to the order again afterwards.
1695 Support units when adding assembly items or assortment items. Currently the
1696 default unit of the item is always used.
1700 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1701 consists of other assemblies.
1707 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>