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);
24 use Rose::Object::MakeMethods::Generic (
25 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
26 makemodels shops_not_assigned
29 assortment assortment_items assembly assembly_items
30 all_pricegroups all_translations all_partsgroups all_units
31 all_buchungsgruppen all_payment_terms all_warehouses
32 parts_classification_filter
33 all_languages all_units all_price_factors) ],
34 'scalar' => [ qw(warehouse bin) ],
38 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
39 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
41 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
43 # actions for editing parts
46 my ($self, %params) = @_;
48 $self->part( SL::DB::Part->new_part );
52 sub action_add_service {
53 my ($self, %params) = @_;
55 $self->part( SL::DB::Part->new_service );
59 sub action_add_assembly {
60 my ($self, %params) = @_;
62 $self->part( SL::DB::Part->new_assembly );
66 sub action_add_assortment {
67 my ($self, %params) = @_;
69 $self->part( SL::DB::Part->new_assortment );
73 sub action_add_from_record {
76 check_has_valid_part_type($::form->{part}{part_type});
78 die 'parts_classification_type must be "sales" or "purchases"'
79 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
88 check_has_valid_part_type($::form->{part_type});
90 $self->action_add_part if $::form->{part_type} eq 'part';
91 $self->action_add_service if $::form->{part_type} eq 'service';
92 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
93 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
97 my ($self, %params) = @_;
99 # checks that depend only on submitted $::form
100 $self->check_form or return $self->js->render;
102 my $is_new = !$self->part->id; # $ part gets loaded here
104 # check that the part hasn't been modified
106 $self->check_part_not_modified or
107 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;
111 && $::form->{part}{partnumber}
112 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
114 return $self->js->error(t8('The partnumber is already being used'))->render;
119 my @errors = $self->part->validate;
120 return $self->js->error(@errors)->render if @errors;
122 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
123 $self->part->db->with_transaction(sub {
125 if ( $params{save_as_new} ) {
126 $self->part( $self->part->clone_and_reset_deep );
127 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
130 $self->part->save(cascade => 1);
132 SL::DB::History->new(
133 trans_id => $self->part->id,
134 snumbers => 'partnumber_' . $self->part->partnumber,
135 employee_id => SL::DB::Manager::Employee->current->id,
140 CVar->save_custom_variables(
141 dbh => $self->part->db->dbh,
143 trans_id => $self->part->id,
144 variables => $::form, # $::form->{cvar} would be nicer
149 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
152 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
154 if ( $::form->{callback} ) {
155 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
158 # default behaviour after save: reload item, this also resets last_modification!
159 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
163 sub action_save_as_new {
165 $self->action_save(save_as_new=>1);
171 my $db = $self->part->db; # $self->part has a get_set_init on $::form
173 my $partnumber = $self->part->partnumber; # remember for history log
178 # delete part, together with relationships that don't already
179 # have an ON DELETE CASCADE, e.g. makemodel and translation.
180 $self->part->delete(cascade => 1);
182 SL::DB::History->new(
183 trans_id => $self->part->id,
184 snumbers => 'partnumber_' . $partnumber,
185 employee_id => SL::DB::Manager::Employee->current->id,
187 addition => 'DELETED',
190 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
192 flash_later('info', t8('The item has been deleted.'));
193 if ( $::form->{callback} ) {
194 $self->redirect_to($::form->unescape($::form->{callback}));
196 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
200 sub action_use_as_new {
201 my ($self, %params) = @_;
203 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
204 $::form->{oldpartnumber} = $oldpart->partnumber;
206 $self->part($oldpart->clone_and_reset_deep);
208 $self->part->partnumber(undef);
214 my ($self, %params) = @_;
220 my ($self, %params) = @_;
222 $self->_set_javascript;
223 $self->_setup_form_action_bar;
225 my (%assortment_vars, %assembly_vars);
226 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
227 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
229 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
231 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
232 if (scalar @{ $params{CUSTOM_VARIABLES} });
234 my %title_hash = ( part => t8('Edit Part'),
235 assembly => t8('Edit Assembly'),
236 service => t8('Edit Service'),
237 assortment => t8('Edit Assortment'),
240 $self->part->prices([]) unless $self->part->prices;
241 $self->part->translations([]) unless $self->part->translations;
245 title => $title_hash{$self->part->part_type},
248 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
249 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
250 oldpartnumber => $::form->{oldpartnumber},
251 old_id => $::form->{old_id},
259 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
260 $_[0]->render('part/history', { layout => 0 },
261 history_entries => $history_entries);
264 sub action_update_item_totals {
267 my $part_type = $::form->{part_type};
268 die unless $part_type =~ /^(assortment|assembly)$/;
270 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
271 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
273 my $sum_diff = $sellprice_sum-$lastcost_sum;
276 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
277 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
278 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
279 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
280 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
281 ->no_flash_clear->render();
284 sub action_add_multi_assortment_items {
287 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
288 my $html = $self->render_assortment_items_to_html($item_objects);
290 $self->js->run('kivi.Part.close_picker_dialogs')
291 ->append('#assortment_rows', $html)
292 ->run('kivi.Part.renumber_positions')
293 ->run('kivi.Part.assortment_recalc')
297 sub action_add_multi_assembly_items {
300 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
302 foreach my $item (@{$item_objects}) {
303 my $errstr = validate_assembly($item->part,$self->part);
304 $self->js->flash('error',$errstr) if $errstr;
305 push (@checked_objects,$item) unless $errstr;
308 my $html = $self->render_assembly_items_to_html(\@checked_objects);
310 $self->js->run('kivi.Part.close_picker_dialogs')
311 ->append('#assembly_rows', $html)
312 ->run('kivi.Part.renumber_positions')
313 ->run('kivi.Part.assembly_recalc')
317 sub action_add_assortment_item {
318 my ($self, %params) = @_;
320 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
322 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
324 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
325 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
326 return $self->js->flash('error', t8("This part has already been added."))->render;
329 my $number_of_items = scalar @{$self->assortment_items};
330 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
331 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
333 push(@{$self->assortment_items}, @{$item_objects});
334 my $part = SL::DB::Part->new(part_type => 'assortment');
335 $part->assortment_items(@{$self->assortment_items});
336 my $items_sellprice_sum = $part->items_sellprice_sum;
337 my $items_lastcost_sum = $part->items_lastcost_sum;
338 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
341 ->append('#assortment_rows' , $html) # append in tbody
342 ->val('.add_assortment_item_input' , '')
343 ->run('kivi.Part.focus_last_assortment_input')
344 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
345 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
346 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
347 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
348 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
352 sub action_add_assembly_item {
355 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
357 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
359 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
361 my $duplicate_warning = 0; # duplicates are allowed, just warn
362 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
363 $duplicate_warning++;
366 my $number_of_items = scalar @{$self->assembly_items};
367 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
369 foreach my $item (@{$item_objects}) {
370 my $errstr = validate_assembly($item->part,$self->part);
371 return $self->js->flash('error',$errstr)->render if $errstr;
376 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
378 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
380 push(@{$self->assembly_items}, @{$item_objects});
381 my $part = SL::DB::Part->new(part_type => 'assembly');
382 $part->assemblies(@{$self->assembly_items});
383 my $items_sellprice_sum = $part->items_sellprice_sum;
384 my $items_lastcost_sum = $part->items_lastcost_sum;
385 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
388 ->append('#assembly_rows', $html) # append in tbody
389 ->val('.add_assembly_item_input' , '')
390 ->run('kivi.Part.focus_last_assembly_input')
391 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
392 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
393 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
394 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
395 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
399 sub action_show_multi_items_dialog {
400 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
401 all_partsgroups => SL::DB::Manager::PartsGroup->get_all
405 sub action_multi_items_update_result {
408 $::form->{multi_items}->{filter}->{obsolete} = 0;
410 my $count = $_[0]->multi_items_models->count;
413 my $text = escape($::locale->text('No results.'));
414 $_[0]->render($text, { layout => 0 });
415 } elsif ($count > $max_count) {
416 my $text = escpae($::locale->text('Too many results (#1 from #2).', $count, $max_count));
417 $_[0]->render($text, { layout => 0 });
419 my $multi_items = $_[0]->multi_items_models->get;
420 $_[0]->render('part/_multi_items_result', { layout => 0 },
421 multi_items => $multi_items);
425 sub action_add_makemodel_row {
428 my $vendor_id = $::form->{add_makemodel};
430 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
431 return $self->js->error(t8("No vendor selected or found!"))->render;
433 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
434 $self->js->flash('info', t8("This vendor has already been added."));
437 my $position = scalar @{$self->makemodels} + 1;
439 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
443 sortorder => $position,
444 ) or die "Can't create MakeModel object";
446 my $row_as_html = $self->p->render('part/_makemodel_row',
448 listrow => $position % 2 ? 0 : 1,
451 # after selection focus on the model field in the row that was just added
453 ->append('#makemodel_rows', $row_as_html) # append in tbody
454 ->val('.add_makemodel_input', '')
455 ->run('kivi.Part.focus_last_makemodel_input')
459 sub action_add_customerprice_row {
462 my $customer_id = $::form->{add_customerprice};
464 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
465 or return $self->js->error(t8("No customer selected or found!"))->render;
467 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
468 $self->js->flash('info', t8("This customer has already been added."));
471 my $position = scalar @{ $self->customerprices } + 1;
473 my $cu = SL::DB::PartCustomerPrice->new(
474 customer_id => $customer->id,
475 customer_partnumber => '',
477 sortorder => $position,
478 ) or die "Can't create Customerprice object";
480 my $row_as_html = $self->p->render(
481 'part/_customerprice_row',
482 customerprice => $cu,
483 listrow => $position % 2 ? 0
487 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
488 ->val('.add_customerprice_input', '')
489 ->run('kivi.Part.focus_last_customerprice_input')->render;
492 sub action_reorder_items {
495 my $part_type = $::form->{part_type};
498 partnumber => sub { $_[0]->part->partnumber },
499 description => sub { $_[0]->part->description },
500 qty => sub { $_[0]->qty },
501 sellprice => sub { $_[0]->part->sellprice },
502 lastcost => sub { $_[0]->part->lastcost },
503 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
506 my $method = $sort_keys{$::form->{order_by}};
509 if ($part_type eq 'assortment') {
510 @items = @{ $self->assortment_items };
512 @items = @{ $self->assembly_items };
515 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
516 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
517 if ($::form->{sort_dir}) {
518 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
520 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
523 if ($::form->{sort_dir}) {
524 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
526 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
530 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
533 sub action_warehouse_changed {
536 if ($::form->{warehouse_id} ) {
537 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
538 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
540 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
541 $self->bin($self->warehouse->bins->[0]);
543 ->html('#bin', $self->build_bin_select)
544 ->focus('#part_bin_id');
545 return $self->js->render;
549 # no warehouse was selected, empty the bin field and reset the id
551 ->val('#part_bin_id', undef)
554 return $self->js->render;
557 sub action_ajax_autocomplete {
558 my ($self, %params) = @_;
560 # if someone types something, and hits enter, assume he entered the full name.
561 # if something matches, treat that as sole match
562 # since we need a second get models instance with different filters for that,
563 # we only modify the original filter temporarily in place
564 if ($::form->{prefer_exact}) {
565 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
567 my $exact_models = SL::Controller::Helper::GetModels->new(
570 paginated => { per_page => 2 },
571 with_objects => [ qw(unit_obj classification) ],
574 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
575 $self->parts($exact_matches);
581 value => $_->displayable_name,
582 label => $_->displayable_name,
584 partnumber => $_->partnumber,
585 description => $_->description,
587 part_type => $_->part_type,
589 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
591 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
593 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
596 sub action_test_page {
597 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
600 sub action_part_picker_search {
601 $_[0]->render('part/part_picker_search', { layout => 0 });
604 sub action_part_picker_result {
605 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
611 if ($::request->type eq 'json') {
616 $part_hash = $self->part->as_tree;
617 $part_hash->{cvars} = $self->part->cvar_as_hashref;
620 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
625 sub validate_add_items {
626 scalar @{$::form->{add_items}};
629 sub prepare_assortment_render_vars {
632 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
633 items_lastcost_sum => $self->part->items_lastcost_sum,
634 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
636 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
641 sub prepare_assembly_render_vars {
644 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
645 items_lastcost_sum => $self->part->items_lastcost_sum,
646 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
648 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
656 check_has_valid_part_type($self->part->part_type);
658 $self->_set_javascript;
659 $self->_setup_form_action_bar;
661 my %title_hash = ( part => t8('Add Part'),
662 assembly => t8('Add Assembly'),
663 service => t8('Add Service'),
664 assortment => t8('Add Assortment'),
669 title => $title_hash{$self->part->part_type},
674 sub _set_javascript {
676 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
677 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
680 sub recalc_item_totals {
681 my ($self, %params) = @_;
683 if ( $params{part_type} eq 'assortment' ) {
684 return 0 unless scalar @{$self->assortment_items};
685 } elsif ( $params{part_type} eq 'assembly' ) {
686 return 0 unless scalar @{$self->assembly_items};
688 carp "can only calculate sum for assortments and assemblies";
691 my $part = SL::DB::Part->new(part_type => $params{part_type});
692 if ( $part->is_assortment ) {
693 $part->assortment_items( @{$self->assortment_items} );
694 if ( $params{price_type} eq 'lastcost' ) {
695 return $part->items_lastcost_sum;
697 if ( $params{pricegroup_id} ) {
698 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
700 return $part->items_sellprice_sum;
703 } elsif ( $part->is_assembly ) {
704 $part->assemblies( @{$self->assembly_items} );
705 if ( $params{price_type} eq 'lastcost' ) {
706 return $part->items_lastcost_sum;
708 return $part->items_sellprice_sum;
713 sub check_part_not_modified {
716 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
723 my $is_new = !$self->part->id;
725 my $params = delete($::form->{part}) || { };
727 delete $params->{id};
728 $self->part->assign_attributes(%{ $params});
729 $self->part->bin_id(undef) unless $self->part->warehouse_id;
731 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
732 # will be the case for used assortments when saving, or when a used assortment
734 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
735 $self->part->assortment_items([]);
736 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
739 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
740 $self->part->assemblies([]); # completely rewrite assortments each time
741 $self->part->add_assemblies( @{ $self->assembly_items } );
744 $self->part->translations([]);
745 $self->parse_form_translations;
747 $self->part->prices([]);
748 $self->parse_form_prices;
750 $self->parse_form_customerprices;
751 $self->parse_form_makemodels;
754 sub parse_form_prices {
756 # only save prices > 0
757 my $prices = delete($::form->{prices}) || [];
758 foreach my $price ( @{$prices} ) {
759 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
760 next unless $sellprice > 0; # skip negative prices as well
761 my $p = SL::DB::Price->new(parts_id => $self->part->id,
762 pricegroup_id => $price->{pricegroup_id},
765 $self->part->add_prices($p);
769 sub parse_form_translations {
771 # don't add empty translations
772 my $translations = delete($::form->{translations}) || [];
773 foreach my $translation ( @{$translations} ) {
774 next unless $translation->{translation};
775 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
776 $self->part->add_translations( $translation );
780 sub parse_form_makemodels {
784 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
785 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
788 $self->part->makemodels([]);
791 my $makemodels = delete($::form->{makemodels}) || [];
792 foreach my $makemodel ( @{$makemodels} ) {
793 next unless $makemodel->{make};
795 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
797 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
798 id => $makemodel->{id},
799 make => $makemodel->{make},
800 model => $makemodel->{model} || '',
801 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
802 sortorder => $position,
804 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
805 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
806 # don't change lastupdate
807 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
808 # new makemodel, no lastcost entered, leave lastupdate empty
809 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
810 # lastcost hasn't changed, use original lastupdate
811 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
813 $mm->lastupdate(DateTime->now);
815 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
816 $self->part->add_makemodels($mm);
820 sub parse_form_customerprices {
823 my $customerprices_map;
824 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
825 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
828 $self->part->customerprices([]);
831 my $customerprices = delete($::form->{customerprices}) || [];
832 foreach my $customerprice ( @{$customerprices} ) {
833 next unless $customerprice->{customer_id};
835 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
837 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
838 id => $customerprice->{id},
839 customer_id => $customerprice->{customer_id},
840 customer_partnumber => $customerprice->{customer_partnumber} || '',
841 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
842 sortorder => $position,
844 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
845 # lastupdate isn't set, original price is 0 and new lastcost is 0
846 # don't change lastupdate
847 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
848 # new customerprice, no lastcost entered, leave lastupdate empty
849 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
850 # price hasn't changed, use original lastupdate
851 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
853 $cu->lastupdate(DateTime->now);
855 $self->part->add_customerprices($cu);
859 sub build_bin_select {
860 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
861 title_key => 'description',
862 default => $_[0]->bin->id,
866 # get_set_inits for partpicker
869 if ($::form->{no_paginate}) {
870 $_[0]->models->disable_plugin('paginated');
876 # get_set_inits for part controller
880 # used by edit, save, delete and add
882 if ( $::form->{part}{id} ) {
883 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
885 die "part_type missing" unless $::form->{part}{part_type};
886 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
892 return $self->part->orphaned;
898 SL::Controller::Helper::GetModels->new(
905 partnumber => t8('Partnumber'),
906 description => t8('Description'),
908 with_objects => [ qw(unit_obj classification) ],
917 sub init_assortment_items {
918 # this init is used while saving and whenever assortments change dynamically
922 my $assortment_items = delete($::form->{assortment_items}) || [];
923 foreach my $assortment_item ( @{$assortment_items} ) {
924 next unless $assortment_item->{parts_id};
926 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
927 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
928 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
929 charge => $assortment_item->{charge},
930 unit => $assortment_item->{unit} || $part->unit,
931 position => $position,
939 sub init_makemodels {
943 my @makemodel_array = ();
944 my $makemodels = delete($::form->{makemodels}) || [];
946 foreach my $makemodel ( @{$makemodels} ) {
947 next unless $makemodel->{make};
949 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
950 id => $makemodel->{id},
951 make => $makemodel->{make},
952 model => $makemodel->{model} || '',
953 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
954 sortorder => $position,
955 ) or die "Can't create mm";
956 # $mm->id($makemodel->{id}) if $makemodel->{id};
957 push(@makemodel_array, $mm);
959 return \@makemodel_array;
962 sub init_customerprices {
966 my @customerprice_array = ();
967 my $customerprices = delete($::form->{customerprices}) || [];
969 foreach my $customerprice ( @{$customerprices} ) {
970 next unless $customerprice->{customer_id};
972 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
973 id => $customerprice->{id},
974 customer_partnumber => $customerprice->{customer_partnumber},
975 customer_id => $customerprice->{customer_id} || '',
976 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
977 sortorder => $position,
978 ) or die "Can't create cu";
979 # $cu->id($customerprice->{id}) if $customerprice->{id};
980 push(@customerprice_array, $cu);
982 return \@customerprice_array;
985 sub init_assembly_items {
989 my $assembly_items = delete($::form->{assembly_items}) || [];
990 foreach my $assembly_item ( @{$assembly_items} ) {
991 next unless $assembly_item->{parts_id};
993 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
994 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
995 bom => $assembly_item->{bom},
996 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
997 position => $position,
1004 sub init_all_warehouses {
1006 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1009 sub init_all_languages {
1010 SL::DB::Manager::Language->get_all_sorted;
1013 sub init_all_partsgroups {
1015 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1018 sub init_all_buchungsgruppen {
1020 if ( $self->part->orphaned ) {
1021 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1023 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
1027 sub init_shops_not_assigned {
1030 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1031 if ( @used_shop_ids ) {
1032 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1035 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1039 sub init_all_units {
1041 if ( $self->part->orphaned ) {
1042 return SL::DB::Manager::Unit->get_all_sorted;
1044 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1048 sub init_all_payment_terms {
1050 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1053 sub init_all_price_factors {
1054 SL::DB::Manager::PriceFactor->get_all_sorted;
1057 sub init_all_pricegroups {
1058 SL::DB::Manager::Pricegroup->get_all_sorted;
1061 # model used to filter/display the parts in the multi-items dialog
1062 sub init_multi_items_models {
1063 SL::Controller::Helper::GetModels->new(
1064 controller => $_[0],
1066 with_objects => [ qw(unit_obj partsgroup classification) ],
1067 disable_plugin => 'paginated',
1068 source => $::form->{multi_items},
1074 partnumber => t8('Partnumber'),
1075 description => t8('Description')}
1079 sub init_parts_classification_filter {
1080 return [] unless $::form->{parts_classification_type};
1082 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1083 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1085 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1088 # simple checks to run on $::form before saving
1090 sub form_check_part_description_exists {
1093 return 1 if $::form->{part}{description};
1095 $self->js->flash('error', t8('Part Description missing!'))
1096 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1097 ->focus('#part_description');
1101 sub form_check_assortment_items_exist {
1104 return 1 unless $::form->{part}{part_type} eq 'assortment';
1105 # skip item check for existing assortments that have been used
1106 return 1 if ($self->part->id and !$self->part->orphaned);
1108 # new or orphaned parts must have items in $::form->{assortment_items}
1109 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1110 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1111 ->focus('#add_assortment_item_name')
1112 ->flash('error', t8('The assortment doesn\'t have any items.'));
1118 sub form_check_assortment_items_unique {
1121 return 1 unless $::form->{part}{part_type} eq 'assortment';
1123 my %duplicate_elements;
1125 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1126 $duplicate_elements{$_}++ if $count{$_}++;
1129 if ( keys %duplicate_elements ) {
1130 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1131 ->flash('error', t8('There are duplicate assortment items'));
1137 sub form_check_assembly_items_exist {
1140 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1142 # skip item check for existing assembly that have been used
1143 return 1 if ($self->part->id and !$self->part->orphaned);
1145 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1146 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1147 ->focus('#add_assembly_item_name')
1148 ->flash('error', t8('The assembly doesn\'t have any items.'));
1154 sub form_check_partnumber_is_unique {
1157 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1158 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1160 $self->js->flash('error', t8('The partnumber already exists!'))
1161 ->focus('#part_description');
1168 # general checking functions
1171 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1177 $self->form_check_part_description_exists || return 0;
1178 $self->form_check_assortment_items_exist || return 0;
1179 $self->form_check_assortment_items_unique || return 0;
1180 $self->form_check_assembly_items_exist || return 0;
1181 $self->form_check_partnumber_is_unique || return 0;
1186 sub check_has_valid_part_type {
1187 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1190 sub render_assortment_items_to_html {
1191 my ($self, $assortment_items, $number_of_items) = @_;
1193 my $position = $number_of_items + 1;
1195 foreach my $ai (@$assortment_items) {
1196 $html .= $self->p->render('part/_assortment_row',
1197 PART => $self->part,
1198 orphaned => $self->orphaned,
1200 listrow => $position % 2 ? 1 : 0,
1201 position => $position, # for legacy assemblies
1208 sub render_assembly_items_to_html {
1209 my ($self, $assembly_items, $number_of_items) = @_;
1211 my $position = $number_of_items + 1;
1213 foreach my $ai (@{$assembly_items}) {
1214 $html .= $self->p->render('part/_assembly_row',
1215 PART => $self->part,
1216 orphaned => $self->orphaned,
1218 listrow => $position % 2 ? 1 : 0,
1219 position => $position, # for legacy assemblies
1226 sub parse_add_items_to_objects {
1227 my ($self, %params) = @_;
1228 my $part_type = $params{part_type};
1229 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1230 my $position = $params{position} || 1;
1232 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1235 foreach my $item ( @add_items ) {
1236 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1238 if ( $part_type eq 'assortment' ) {
1239 $ai = SL::DB::AssortmentItem->new(part => $part,
1240 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1241 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1242 position => $position,
1243 ) or die "Can't create AssortmentItem from item";
1244 } elsif ( $part_type eq 'assembly' ) {
1245 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1246 # id => $self->assembly->id, # will be set on save
1247 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1248 bom => 0, # default when adding: no bom
1249 position => $position,
1252 die "part_type must be assortment or assembly";
1254 push(@item_objects, $ai);
1258 return \@item_objects;
1261 sub _setup_form_action_bar {
1264 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1266 for my $bar ($::request->layout->get('actionbar')) {
1271 call => [ 'kivi.Part.save' ],
1272 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1273 accesskey => 'enter',
1277 call => [ 'kivi.Part.use_as_new' ],
1278 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1279 : !$may_edit ? t8('You do not have the permissions to access this function.')
1282 ], # end of combobox "Save"
1286 call => [ 'kivi.Part.delete' ],
1287 confirm => t8('Do you really want to delete this object?'),
1288 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1289 : !$may_edit ? t8('You do not have the permissions to access this function.')
1290 : !$self->part->orphaned ? t8('This object has already been used.')
1298 call => [ 'kivi.Part.open_history_popup' ],
1299 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1300 : !$may_edit ? t8('You do not have the permissions to access this function.')
1315 SL::Controller::Part - Part CRUD controller
1319 Controller for adding/editing/saving/deleting parts.
1321 All the relations are loaded at once and saving the part, adding a history
1322 entry and saving CVars happens inside one transaction. When saving the old
1323 relations are deleted and written as new to the database.
1325 Relations for parts:
1333 =item assembly items
1335 =item assortment items
1343 There are 4 different part types:
1349 The "default" part type.
1351 inventory_accno_id is set.
1355 Services can't be stocked.
1357 inventory_accno_id isn't set.
1361 Assemblies consist of other parts, services, assemblies or assortments. They
1362 aren't meant to be bought, only sold. To add assemblies to stock you typically
1363 have to make them, which reduces the stock by its respective components. Once
1364 an assembly item has been created there is currently no way to "disassemble" it
1365 again. An assembly item can appear several times in one assembly. An assmbly is
1366 sold as one item with a defined sellprice and lastcost. If the component prices
1367 change the assortment price remains the same. The assembly items may be printed
1368 in a record if the item's "bom" is set.
1372 Similar to assembly, but each assortment item may only appear once per
1373 assortment. When selling an assortment the assortment items are added to the
1374 record together with the assortment, which is added with sellprice 0.
1376 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1377 determined by the sum of the current assortment item prices when the assortment
1378 is added to a record. This also means that price rules and customer discounts
1379 will be applied to the assortment items.
1381 Once the assortment items have been added they may be modified or deleted, just
1382 as if they had been added manually, the individual assortment items aren't
1383 linked to the assortment or the other assortment items in any way.
1391 =item C<action_add_part>
1393 =item C<action_add_service>
1395 =item C<action_add_assembly>
1397 =item C<action_add_assortment>
1399 =item C<action_add PART_TYPE>
1401 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1402 parameter part_type as an action. Example:
1404 controller.pl?action=Part/add&part_type=service
1406 =item C<action_add_from_record>
1408 When adding new items to records they can be created on the fly if the entered
1409 partnumber or description doesn't exist yet. After being asked what part type
1410 the new item should have the user is redirected to the correct edit page.
1412 Depending on whether the item was added from a sales or a purchase record, only
1413 the relevant part classifications should be selectable for new item, so this
1414 parameter is passed on via a hidden parts_classification_type in the new_item
1417 =item C<action_save>
1419 Saves the current part and then reloads the edit page for the part.
1421 =item C<action_use_as_new>
1423 Takes the information from the current part, plus any modifications made on the
1424 page, and creates a new edit page that is ready to be saved. The partnumber is
1425 set empty, so a new partnumber from the number range will be used if the user
1426 doesn't enter one manually.
1428 Unsaved changes to the original part aren't updated.
1430 The part type cannot be changed in this way.
1432 =item C<action_delete>
1434 Deletes the current part and then redirects to the main page, there is no
1437 The delete button only appears if the part is 'orphaned', according to
1438 SL::DB::Part orphaned.
1440 The part can't be deleted if it appears in invoices, orders, delivery orders,
1441 the inventory, or is part of an assembly or assortment.
1443 If the part is deleted its relations prices, makdemodel, assembly,
1444 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1446 Before this controller items that appeared in inventory didn't count as
1447 orphaned and could be deleted and the inventory entries were also deleted, this
1448 "feature" hasn't been implemented.
1450 =item C<action_edit part.id>
1452 Load and display a part for editing.
1454 controller.pl?action=Part/edit&part.id=12345
1456 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1460 =head1 BUTTON ACTIONS
1466 Opens a popup displaying all the history entries. Once a new history controller
1467 is written the button could link there instead, with the part already selected.
1475 =item C<action_update_item_totals>
1477 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1478 amount of an item changes. The sum of all sellprices and lastcosts is
1479 calculated and the totals updated. Uses C<recalc_item_totals>.
1481 =item C<action_add_assortment_item>
1483 Adds a new assortment item from a part picker seleciton to the assortment item list
1485 If the item already exists in the assortment the item isn't added and a Flash
1488 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1489 after adding each new item, add the new object to the item objects that were
1490 already parsed, calculate totals via a dummy part then update the row and the
1493 =item C<action_add_assembly_item>
1495 Adds a new assembly item from a part picker seleciton to the assembly item list
1497 If the item already exists in the assembly a flash info is generated, but the
1500 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1501 after adding each new item, add the new object to the item objects that were
1502 already parsed, calculate totals via a dummy part then update the row and the
1505 =item C<action_add_multi_assortment_items>
1507 Parses the items to be added from the form generated by the multi input and
1508 appends the html of the tr-rows to the assortment item table. Afterwards all
1509 assortment items are renumbered and the sums recalculated via
1510 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1512 =item C<action_add_multi_assembly_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 assembly item table. Afterwards all
1516 assembly items are renumbered and the sums recalculated via
1517 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1519 =item C<action_show_multi_items_dialog>
1521 =item C<action_multi_items_update_result>
1523 =item C<action_add_makemodel_row>
1525 Add a new makemodel row with the vendor that was selected via the vendor
1528 Checks the already existing makemodels and warns if a row with that vendor
1529 already exists. Currently it is possible to have duplicate vendor rows.
1531 =item C<action_reorder_items>
1533 Sorts the item table for assembly or assortment items.
1535 =item C<action_warehouse_changed>
1539 =head1 ACTIONS part picker
1543 =item C<action_ajax_autocomplete>
1545 =item C<action_test_page>
1547 =item C<action_part_picker_search>
1549 =item C<action_part_picker_result>
1551 =item C<action_show>
1561 Calls some simple checks that test the submitted $::form for obvious errors.
1562 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1564 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1565 some cases extra actions are taken, e.g. if the part description is missing the
1566 basic data tab is selected and the description input field is focussed.
1572 =item C<form_check_part_description_exists>
1574 =item C<form_check_assortment_items_exist>
1576 =item C<form_check_assortment_items_unique>
1578 =item C<form_check_assembly_items_exist>
1580 =item C<form_check_partnumber_is_unique>
1584 =head1 HELPER FUNCTIONS
1590 When submitting the form for saving, parses the transmitted form. Expects the
1594 $::form->{makemodels}
1595 $::form->{translations}
1597 $::form->{assemblies}
1598 $::form->{assortments}
1600 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1602 =item C<recalc_item_totals %params>
1604 Helper function for calculating the total lastcost and sellprice for assemblies
1605 or assortments according to their items, which are parsed from the current
1608 Is called whenever the qty of an item is changed or items are deleted.
1612 * part_type : 'assortment' or 'assembly' (mandatory)
1614 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1616 Depending on the price_type the lastcost sum or sellprice sum is returned.
1618 Doesn't work for recursive items.
1622 =head1 GET SET INITS
1624 There are get_set_inits for
1632 which parse $::form and automatically create an array of objects.
1634 These inits are used during saving and each time a new element is added.
1638 =item C<init_makemodels>
1640 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1641 $self->part->makemodels, ready to be saved.
1643 Used for saving parts and adding new makemodel rows.
1645 =item C<parse_add_items_to_objects PART_TYPE>
1647 Parses the resulting form from either the part-picker submit or the multi-item
1648 submit, and creates an arrayref of assortment_item or assembly objects, that
1649 can be rendered via C<render_assortment_items_to_html> or
1650 C<render_assembly_items_to_html>.
1652 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1653 Optional param: position (used for numbering and listrow class)
1655 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1657 Takes an array_ref of assortment_items, and generates tables rows ready for
1658 adding to the assortment table. Is used when a part is loaded, or whenever new
1659 assortment items are added.
1661 =item C<parse_form_makemodels>
1663 Makemodels can't just be overwritten, because of the field "lastupdate", that
1664 remembers when the lastcost for that vendor changed the last time.
1666 So the original values are cloned and remembered, so we can compare if lastcost
1667 was changed in $::form, and keep or update lastupdate.
1669 lastcost isn't updated until the first time it was saved with a value, until
1672 Also a boolean "makemodel" needs to be written in parts, depending on whether
1673 makemodel entries exist or not.
1675 We still need init_makemodels for when we open the part for editing.
1685 It should be possible to jump to the edit page in a specific tab
1689 Support callbacks, e.g. creating a new part from within an order, and jumping
1690 back to the order again afterwards.
1694 Support units when adding assembly items or assortment items. Currently the
1695 default unit of the item is always used.
1699 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1700 consists of other assemblies.
1706 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>