1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::DB::PartsGroup;
9 use SL::DB::PriceRuleItem;
11 use SL::Controller::Helper::GetModels;
12 use SL::Locale::String qw(t8);
14 use List::Util qw(sum);
15 use List::UtilsBy qw(extract_by);
16 use SL::Helper::Flash;
20 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
22 use SL::MoreCommon qw(save_form);
24 use SL::Presenter::EscapedText qw(escape is_escaped);
25 use SL::Presenter::Tag qw(select_tag);
27 use Rose::Object::MakeMethods::Generic (
28 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
29 makemodels shops_not_assigned
32 assortment assortment_items assembly assembly_items
33 all_pricegroups all_translations all_partsgroups all_units
34 all_buchungsgruppen all_payment_terms all_warehouses
35 parts_classification_filter
36 all_languages all_units all_price_factors) ],
37 'scalar' => [ qw(warehouse bin stock_amounts journal) ],
41 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
42 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
44 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
46 # actions for editing parts
49 my ($self, %params) = @_;
51 $self->part( SL::DB::Part->new_part );
55 sub action_add_service {
56 my ($self, %params) = @_;
58 $self->part( SL::DB::Part->new_service );
62 sub action_add_assembly {
63 my ($self, %params) = @_;
65 $self->part( SL::DB::Part->new_assembly );
69 sub action_add_assortment {
70 my ($self, %params) = @_;
72 $self->part( SL::DB::Part->new_assortment );
76 sub action_add_from_record {
79 check_has_valid_part_type($::form->{part}{part_type});
81 die 'parts_classification_type must be "sales" or "purchases"'
82 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
91 check_has_valid_part_type($::form->{part_type});
93 $self->action_add_part if $::form->{part_type} eq 'part';
94 $self->action_add_service if $::form->{part_type} eq 'service';
95 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
96 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
100 my ($self, %params) = @_;
102 # checks that depend only on submitted $::form
103 $self->check_form or return $self->js->render;
105 my $is_new = !$self->part->id; # $ part gets loaded here
107 # check that the part hasn't been modified
109 $self->check_part_not_modified or
110 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;
114 && $::form->{part}{partnumber}
115 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
117 return $self->js->error(t8('The partnumber is already being used'))->render;
122 my @errors = $self->part->validate;
123 return $self->js->error(@errors)->render if @errors;
125 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
126 $self->part->db->with_transaction(sub {
128 $self->part->save(cascade => 1);
130 SL::DB::History->new(
131 trans_id => $self->part->id,
132 snumbers => 'partnumber_' . $self->part->partnumber,
133 employee_id => SL::DB::Manager::Employee->current->id,
138 CVar->save_custom_variables(
139 dbh => $self->part->db->dbh,
141 trans_id => $self->part->id,
142 variables => $::form, # $::form->{cvar} would be nicer
147 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
149 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
151 if ( $::form->{callback} ) {
152 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
155 # default behaviour after save: reload item, this also resets last_modification!
156 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
163 if ( $::form->{callback} ) {
164 $self->redirect_to($::form->unescape($::form->{callback}));
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 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
232 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
233 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
234 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
237 my %title_hash = ( part => t8('Edit Part'),
238 assembly => t8('Edit Assembly'),
239 service => t8('Edit Service'),
240 assortment => t8('Edit Assortment'),
243 $self->part->prices([]) unless $self->part->prices;
244 $self->part->translations([]) unless $self->part->translations;
248 title => $title_hash{$self->part->part_type},
251 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
252 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
253 oldpartnumber => $::form->{oldpartnumber},
254 old_id => $::form->{old_id},
262 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
263 $_[0]->render('part/history', { layout => 0 },
264 history_entries => $history_entries);
267 sub action_inventory {
270 $::auth->assert('warehouse_contents');
272 $self->stock_amounts($self->part->get_simple_stock_sql);
273 $self->journal($self->part->get_mini_journal);
275 $_[0]->render('part/_inventory_data', { layout => 0 });
278 sub action_update_item_totals {
281 my $part_type = $::form->{part_type};
282 die unless $part_type =~ /^(assortment|assembly)$/;
284 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
285 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
286 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
288 my $sum_diff = $sellprice_sum-$lastcost_sum;
291 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
292 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
293 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
294 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
295 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
296 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
297 ->no_flash_clear->render();
300 sub action_add_multi_assortment_items {
303 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
304 my $html = $self->render_assortment_items_to_html($item_objects);
306 $self->js->run('kivi.Part.close_picker_dialogs')
307 ->append('#assortment_rows', $html)
308 ->run('kivi.Part.renumber_positions')
309 ->run('kivi.Part.assortment_recalc')
313 sub action_add_multi_assembly_items {
316 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
318 foreach my $item (@{$item_objects}) {
319 my $errstr = validate_assembly($item->part,$self->part);
320 $self->js->flash('error',$errstr) if $errstr;
321 push (@checked_objects,$item) unless $errstr;
324 my $html = $self->render_assembly_items_to_html(\@checked_objects);
326 $self->js->run('kivi.Part.close_picker_dialogs')
327 ->append('#assembly_rows', $html)
328 ->run('kivi.Part.renumber_positions')
329 ->run('kivi.Part.assembly_recalc')
333 sub action_add_assortment_item {
334 my ($self, %params) = @_;
336 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
338 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
340 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
341 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
342 return $self->js->flash('error', t8("This part has already been added."))->render;
345 my $number_of_items = scalar @{$self->assortment_items};
346 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
347 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
349 push(@{$self->assortment_items}, @{$item_objects});
350 my $part = SL::DB::Part->new(part_type => 'assortment');
351 $part->assortment_items(@{$self->assortment_items});
352 my $items_sellprice_sum = $part->items_sellprice_sum;
353 my $items_lastcost_sum = $part->items_lastcost_sum;
354 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
357 ->append('#assortment_rows' , $html) # append in tbody
358 ->val('.add_assortment_item_input' , '')
359 ->run('kivi.Part.focus_last_assortment_input')
360 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
361 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
362 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
363 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
364 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
368 sub action_add_assembly_item {
371 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
373 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
375 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
377 my $duplicate_warning = 0; # duplicates are allowed, just warn
378 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
379 $duplicate_warning++;
382 my $number_of_items = scalar @{$self->assembly_items};
383 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
385 foreach my $item (@{$item_objects}) {
386 my $errstr = validate_assembly($item->part,$self->part);
387 return $self->js->flash('error',$errstr)->render if $errstr;
392 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
394 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
396 push(@{$self->assembly_items}, @{$item_objects});
397 my $part = SL::DB::Part->new(part_type => 'assembly');
398 $part->assemblies(@{$self->assembly_items});
399 my $items_sellprice_sum = $part->items_sellprice_sum;
400 my $items_lastcost_sum = $part->items_lastcost_sum;
401 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
402 my $items_weight_sum = $part->items_weight_sum;
405 ->append('#assembly_rows', $html) # append in tbody
406 ->val('.add_assembly_item_input' , '')
407 ->run('kivi.Part.focus_last_assembly_input')
408 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
409 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
410 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
411 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
412 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
413 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
417 sub action_show_multi_items_dialog {
420 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
421 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
422 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
424 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
425 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
426 search_term => $search_term
430 sub action_multi_items_update_result {
431 my $max_count = $::form->{limit};
433 my $count = $_[0]->multi_items_models->count;
436 my $text = escape($::locale->text('No results.'));
437 $_[0]->render($text, { layout => 0 });
438 } elsif ($max_count && $count > $max_count) {
439 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
440 $_[0]->render($text, { layout => 0 });
442 my $multi_items = $_[0]->multi_items_models->get;
443 $_[0]->render('part/_multi_items_result', { layout => 0 },
444 multi_items => $multi_items);
448 sub action_add_makemodel_row {
451 my $vendor_id = $::form->{add_makemodel};
453 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
454 return $self->js->error(t8("No vendor selected or found!"))->render;
456 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
457 $self->js->flash('info', t8("This vendor has already been added."));
460 my $position = scalar @{$self->makemodels} + 1;
462 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
466 sortorder => $position,
467 ) or die "Can't create MakeModel object";
469 my $row_as_html = $self->p->render('part/_makemodel_row',
471 listrow => $position % 2 ? 0 : 1,
474 # after selection focus on the model field in the row that was just added
476 ->append('#makemodel_rows', $row_as_html) # append in tbody
477 ->val('.add_makemodel_input', '')
478 ->run('kivi.Part.focus_last_makemodel_input')
482 sub action_add_customerprice_row {
485 my $customer_id = $::form->{add_customerprice};
487 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
488 or return $self->js->error(t8("No customer selected or found!"))->render;
490 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
491 $self->js->flash('info', t8("This customer has already been added."));
494 my $position = scalar @{ $self->customerprices } + 1;
496 my $cu = SL::DB::PartCustomerPrice->new(
497 customer_id => $customer->id,
498 customer_partnumber => '',
500 sortorder => $position,
501 ) or die "Can't create Customerprice object";
503 my $row_as_html = $self->p->render(
504 'part/_customerprice_row',
505 customerprice => $cu,
506 listrow => $position % 2 ? 0
510 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
511 ->val('.add_customerprice_input', '')
512 ->run('kivi.Part.focus_last_customerprice_input')->render;
515 sub action_reorder_items {
518 my $part_type = $::form->{part_type};
521 partnumber => sub { $_[0]->part->partnumber },
522 description => sub { $_[0]->part->description },
523 qty => sub { $_[0]->qty },
524 sellprice => sub { $_[0]->part->sellprice },
525 lastcost => sub { $_[0]->part->lastcost },
526 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
529 my $method = $sort_keys{$::form->{order_by}};
532 if ($part_type eq 'assortment') {
533 @items = @{ $self->assortment_items };
535 @items = @{ $self->assembly_items };
538 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
539 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
540 if ($::form->{sort_dir}) {
541 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
543 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
546 if ($::form->{sort_dir}) {
547 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
549 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
553 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
556 sub action_warehouse_changed {
559 if ($::form->{warehouse_id} ) {
560 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
561 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
563 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
564 $self->bin($self->warehouse->bins_sorted->[0]);
566 ->html('#bin', $self->build_bin_select)
567 ->focus('#part_bin_id');
568 return $self->js->render;
572 # no warehouse was selected, empty the bin field and reset the id
574 ->val('#part_bin_id', undef)
577 return $self->js->render;
580 sub action_ajax_autocomplete {
581 my ($self, %params) = @_;
583 # if someone types something, and hits enter, assume he entered the full name.
584 # if something matches, treat that as sole match
585 # since we need a second get models instance with different filters for that,
586 # we only modify the original filter temporarily in place
587 if ($::form->{prefer_exact}) {
588 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
589 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
590 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
592 my $exact_models = SL::Controller::Helper::GetModels->new(
595 paginated => { per_page => 2 },
596 with_objects => [ qw(unit_obj classification) ],
599 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
600 $self->parts($exact_matches);
606 value => $_->displayable_name,
607 label => $_->displayable_name,
609 partnumber => $_->partnumber,
610 description => $_->description,
612 part_type => $_->part_type,
614 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
616 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
618 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
621 sub action_test_page {
622 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
625 sub action_part_picker_search {
628 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
629 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
630 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
632 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
635 sub action_part_picker_result {
636 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
642 if ($::request->type eq 'json') {
647 $part_hash = $self->part->as_tree;
648 $part_hash->{cvars} = $self->part->cvar_as_hashref;
651 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
656 sub validate_add_items {
657 scalar @{$::form->{add_items}};
660 sub prepare_assortment_render_vars {
663 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
664 items_lastcost_sum => $self->part->items_lastcost_sum,
665 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
667 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
672 sub prepare_assembly_render_vars {
675 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
677 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
678 items_lastcost_sum => $self->part->items_lastcost_sum,
679 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
681 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
689 check_has_valid_part_type($self->part->part_type);
691 $self->_set_javascript;
692 $self->_setup_form_action_bar;
694 my %title_hash = ( part => t8('Add Part'),
695 assembly => t8('Add Assembly'),
696 service => t8('Add Service'),
697 assortment => t8('Add Assortment'),
702 title => $title_hash{$self->part->part_type},
707 sub _set_javascript {
709 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart);
710 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
713 sub recalc_item_totals {
714 my ($self, %params) = @_;
716 if ( $params{part_type} eq 'assortment' ) {
717 return 0 unless scalar @{$self->assortment_items};
718 } elsif ( $params{part_type} eq 'assembly' ) {
719 return 0 unless scalar @{$self->assembly_items};
721 carp "can only calculate sum for assortments and assemblies";
724 my $part = SL::DB::Part->new(part_type => $params{part_type});
725 if ( $part->is_assortment ) {
726 $part->assortment_items( @{$self->assortment_items} );
727 if ( $params{price_type} eq 'lastcost' ) {
728 return $part->items_lastcost_sum;
730 if ( $params{pricegroup_id} ) {
731 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
733 return $part->items_sellprice_sum;
736 } elsif ( $part->is_assembly ) {
737 $part->assemblies( @{$self->assembly_items} );
738 if ( $params{price_type} eq 'weight' ) {
739 return $part->items_weight_sum;
740 } elsif ( $params{price_type} eq 'lastcost' ) {
741 return $part->items_lastcost_sum;
743 return $part->items_sellprice_sum;
748 sub check_part_not_modified {
751 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
758 my $is_new = !$self->part->id;
760 my $params = delete($::form->{part}) || { };
762 delete $params->{id};
763 $self->part->assign_attributes(%{ $params});
764 $self->part->bin_id(undef) unless $self->part->warehouse_id;
766 $self->normalize_text_blocks;
768 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
769 # will be the case for used assortments when saving, or when a used assortment
771 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
772 $self->part->assortment_items([]);
773 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
776 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
777 $self->part->assemblies([]); # completely rewrite assortments each time
778 $self->part->add_assemblies( @{ $self->assembly_items } );
781 $self->part->translations([]);
782 $self->parse_form_translations;
784 $self->part->prices([]);
785 $self->parse_form_prices;
787 $self->parse_form_customerprices;
788 $self->parse_form_makemodels;
791 sub parse_form_prices {
793 # only save prices > 0
794 my $prices = delete($::form->{prices}) || [];
795 foreach my $price ( @{$prices} ) {
796 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
797 next unless $sellprice > 0; # skip negative prices as well
798 my $p = SL::DB::Price->new(parts_id => $self->part->id,
799 pricegroup_id => $price->{pricegroup_id},
802 $self->part->add_prices($p);
806 sub parse_form_translations {
808 # don't add empty translations
809 my $translations = delete($::form->{translations}) || [];
810 foreach my $translation ( @{$translations} ) {
811 next unless $translation->{translation};
812 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
813 $self->part->add_translations( $translation );
817 sub parse_form_makemodels {
821 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
822 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
825 $self->part->makemodels([]);
828 my $makemodels = delete($::form->{makemodels}) || [];
829 foreach my $makemodel ( @{$makemodels} ) {
830 next unless $makemodel->{make};
832 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
834 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
835 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
837 make => $makemodel->{make},
838 model => $makemodel->{model} || '',
839 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
840 sortorder => $position,
842 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
843 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
844 # don't change lastupdate
845 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
846 # new makemodel, no lastcost entered, leave lastupdate empty
847 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
848 # lastcost hasn't changed, use original lastupdate
849 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
851 $mm->lastupdate(DateTime->now);
853 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
854 $self->part->add_makemodels($mm);
858 sub parse_form_customerprices {
861 my $customerprices_map;
862 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
863 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
866 $self->part->customerprices([]);
869 my $customerprices = delete($::form->{customerprices}) || [];
870 foreach my $customerprice ( @{$customerprices} ) {
871 next unless $customerprice->{customer_id};
873 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
875 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
876 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
878 customer_id => $customerprice->{customer_id},
879 customer_partnumber => $customerprice->{customer_partnumber} || '',
880 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
881 sortorder => $position,
883 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
884 # lastupdate isn't set, original price is 0 and new lastcost is 0
885 # don't change lastupdate
886 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
887 # new customerprice, no lastcost entered, leave lastupdate empty
888 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
889 # price hasn't changed, use original lastupdate
890 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
892 $cu->lastupdate(DateTime->now);
894 $self->part->add_customerprices($cu);
898 sub build_bin_select {
899 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
900 title_key => 'description',
901 default => $_[0]->bin->id,
906 # get_set_inits for partpicker
909 if ($::form->{no_paginate}) {
910 $_[0]->models->disable_plugin('paginated');
916 # get_set_inits for part controller
920 # used by edit, save, delete and add
922 if ( $::form->{part}{id} ) {
923 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
924 } elsif ( $::form->{id} ) {
925 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
927 die "part_type missing" unless $::form->{part}{part_type};
928 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
934 return $self->part->orphaned;
940 SL::Controller::Helper::GetModels->new(
947 partnumber => t8('Partnumber'),
948 description => t8('Description'),
950 with_objects => [ qw(unit_obj classification) ],
959 sub init_assortment_items {
960 # this init is used while saving and whenever assortments change dynamically
964 my $assortment_items = delete($::form->{assortment_items}) || [];
965 foreach my $assortment_item ( @{$assortment_items} ) {
966 next unless $assortment_item->{parts_id};
968 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
969 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
970 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
971 charge => $assortment_item->{charge},
972 unit => $assortment_item->{unit} || $part->unit,
973 position => $position,
981 sub init_makemodels {
985 my @makemodel_array = ();
986 my $makemodels = delete($::form->{makemodels}) || [];
988 foreach my $makemodel ( @{$makemodels} ) {
989 next unless $makemodel->{make};
991 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
992 id => $makemodel->{id},
993 make => $makemodel->{make},
994 model => $makemodel->{model} || '',
995 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
996 sortorder => $position,
997 ) or die "Can't create mm";
998 # $mm->id($makemodel->{id}) if $makemodel->{id};
999 push(@makemodel_array, $mm);
1001 return \@makemodel_array;
1004 sub init_customerprices {
1008 my @customerprice_array = ();
1009 my $customerprices = delete($::form->{customerprices}) || [];
1011 foreach my $customerprice ( @{$customerprices} ) {
1012 next unless $customerprice->{customer_id};
1014 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1015 id => $customerprice->{id},
1016 customer_partnumber => $customerprice->{customer_partnumber},
1017 customer_id => $customerprice->{customer_id} || '',
1018 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1019 sortorder => $position,
1020 ) or die "Can't create cu";
1021 # $cu->id($customerprice->{id}) if $customerprice->{id};
1022 push(@customerprice_array, $cu);
1024 return \@customerprice_array;
1027 sub init_assembly_items {
1031 my $assembly_items = delete($::form->{assembly_items}) || [];
1032 foreach my $assembly_item ( @{$assembly_items} ) {
1033 next unless $assembly_item->{parts_id};
1035 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1036 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1037 bom => $assembly_item->{bom},
1038 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1039 position => $position,
1046 sub init_all_warehouses {
1048 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1051 sub init_all_languages {
1052 SL::DB::Manager::Language->get_all_sorted;
1055 sub init_all_partsgroups {
1057 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1060 sub init_all_buchungsgruppen {
1062 if ( $self->part->orphaned ) {
1063 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1065 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1069 sub init_shops_not_assigned {
1072 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1073 if ( @used_shop_ids ) {
1074 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1077 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1081 sub init_all_units {
1083 if ( $self->part->orphaned ) {
1084 return SL::DB::Manager::Unit->get_all_sorted;
1086 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1090 sub init_all_payment_terms {
1092 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1095 sub init_all_price_factors {
1096 SL::DB::Manager::PriceFactor->get_all_sorted;
1099 sub init_all_pricegroups {
1100 SL::DB::Manager::Pricegroup->get_all_sorted;
1103 # model used to filter/display the parts in the multi-items dialog
1104 sub init_multi_items_models {
1105 SL::Controller::Helper::GetModels->new(
1106 controller => $_[0],
1108 with_objects => [ qw(unit_obj partsgroup classification) ],
1109 disable_plugin => 'paginated',
1110 source => $::form->{multi_items},
1116 partnumber => t8('Partnumber'),
1117 description => t8('Description')}
1121 sub init_parts_classification_filter {
1122 return [] unless $::form->{parts_classification_type};
1124 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1125 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1127 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1130 # simple checks to run on $::form before saving
1132 sub form_check_part_description_exists {
1135 return 1 if $::form->{part}{description};
1137 $self->js->flash('error', t8('Part Description missing!'))
1138 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1139 ->focus('#part_description');
1143 sub form_check_assortment_items_exist {
1146 return 1 unless $::form->{part}{part_type} eq 'assortment';
1147 # skip item check for existing assortments that have been used
1148 return 1 if ($self->part->id and !$self->part->orphaned);
1150 # new or orphaned parts must have items in $::form->{assortment_items}
1151 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1152 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1153 ->focus('#add_assortment_item_name')
1154 ->flash('error', t8('The assortment doesn\'t have any items.'));
1160 sub form_check_assortment_items_unique {
1163 return 1 unless $::form->{part}{part_type} eq 'assortment';
1165 my %duplicate_elements;
1167 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1168 $duplicate_elements{$_}++ if $count{$_}++;
1171 if ( keys %duplicate_elements ) {
1172 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1173 ->flash('error', t8('There are duplicate assortment items'));
1179 sub form_check_assembly_items_exist {
1182 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1184 # skip item check for existing assembly that have been used
1185 return 1 if ($self->part->id and !$self->part->orphaned);
1187 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1188 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1189 ->focus('#add_assembly_item_name')
1190 ->flash('error', t8('The assembly doesn\'t have any items.'));
1196 sub form_check_partnumber_is_unique {
1199 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1200 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1202 $self->js->flash('error', t8('The partnumber already exists!'))
1203 ->focus('#part_description');
1210 # general checking functions
1213 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1219 $self->form_check_part_description_exists || return 0;
1220 $self->form_check_assortment_items_exist || return 0;
1221 $self->form_check_assortment_items_unique || return 0;
1222 $self->form_check_assembly_items_exist || return 0;
1223 $self->form_check_partnumber_is_unique || return 0;
1228 sub check_has_valid_part_type {
1229 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1233 sub normalize_text_blocks {
1236 # check if feature is enabled (select normalize_part_descriptions from defaults)
1237 return unless ($::instance_conf->get_normalize_part_descriptions);
1240 foreach (qw(description)) {
1241 $self->part->{$_} =~ s/\s+$//s;
1242 $self->part->{$_} =~ s/^\s+//s;
1243 $self->part->{$_} =~ s/ {2,}/ /g;
1245 # html block (caveat: can be circumvented by using bold or italics)
1246 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1247 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1251 sub render_assortment_items_to_html {
1252 my ($self, $assortment_items, $number_of_items) = @_;
1254 my $position = $number_of_items + 1;
1256 foreach my $ai (@$assortment_items) {
1257 $html .= $self->p->render('part/_assortment_row',
1258 PART => $self->part,
1259 orphaned => $self->orphaned,
1261 listrow => $position % 2 ? 1 : 0,
1262 position => $position, # for legacy assemblies
1269 sub render_assembly_items_to_html {
1270 my ($self, $assembly_items, $number_of_items) = @_;
1272 my $position = $number_of_items + 1;
1274 foreach my $ai (@{$assembly_items}) {
1275 $html .= $self->p->render('part/_assembly_row',
1276 PART => $self->part,
1277 orphaned => $self->orphaned,
1279 listrow => $position % 2 ? 1 : 0,
1280 position => $position, # for legacy assemblies
1287 sub parse_add_items_to_objects {
1288 my ($self, %params) = @_;
1289 my $part_type = $params{part_type};
1290 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1291 my $position = $params{position} || 1;
1293 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1296 foreach my $item ( @add_items ) {
1297 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1299 if ( $part_type eq 'assortment' ) {
1300 $ai = SL::DB::AssortmentItem->new(part => $part,
1301 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1302 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1303 position => $position,
1304 ) or die "Can't create AssortmentItem from item";
1305 } elsif ( $part_type eq 'assembly' ) {
1306 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1307 # id => $self->assembly->id, # will be set on save
1308 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1309 bom => 0, # default when adding: no bom
1310 position => $position,
1313 die "part_type must be assortment or assembly";
1315 push(@item_objects, $ai);
1319 return \@item_objects;
1322 sub _setup_form_action_bar {
1325 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1326 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1328 for my $bar ($::request->layout->get('actionbar')) {
1333 call => [ 'kivi.Part.save' ],
1334 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1338 call => [ 'kivi.Part.use_as_new' ],
1339 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1340 : !$may_edit ? t8('You do not have the permissions to access this function.')
1343 ], # end of combobox "Save"
1347 submit => [ '#ic', { action => "Part/abort" } ],
1348 only_if => !!$::form->{show_abort},
1353 call => [ 'kivi.Part.delete' ],
1354 confirm => t8('Do you really want to delete this object?'),
1355 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1356 : !$may_edit ? t8('You do not have the permissions to access this function.')
1357 : !$self->part->orphaned ? t8('This object has already been used.')
1358 : $used_in_pricerules ? t8('This object is used in price rules.')
1366 call => [ 'kivi.Part.open_history_popup' ],
1367 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1368 : !$may_edit ? t8('You do not have the permissions to access this function.')
1383 SL::Controller::Part - Part CRUD controller
1387 Controller for adding/editing/saving/deleting parts.
1389 All the relations are loaded at once and saving the part, adding a history
1390 entry and saving CVars happens inside one transaction. When saving the old
1391 relations are deleted and written as new to the database.
1393 Relations for parts:
1401 =item assembly items
1403 =item assortment items
1411 There are 4 different part types:
1417 The "default" part type.
1419 inventory_accno_id is set.
1423 Services can't be stocked.
1425 inventory_accno_id isn't set.
1429 Assemblies consist of other parts, services, assemblies or assortments. They
1430 aren't meant to be bought, only sold. To add assemblies to stock you typically
1431 have to make them, which reduces the stock by its respective components. Once
1432 an assembly item has been created there is currently no way to "disassemble" it
1433 again. An assembly item can appear several times in one assembly. An assmbly is
1434 sold as one item with a defined sellprice and lastcost. If the component prices
1435 change the assortment price remains the same. The assembly items may be printed
1436 in a record if the item's "bom" is set.
1440 Similar to assembly, but each assortment item may only appear once per
1441 assortment. When selling an assortment the assortment items are added to the
1442 record together with the assortment, which is added with sellprice 0.
1444 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1445 determined by the sum of the current assortment item prices when the assortment
1446 is added to a record. This also means that price rules and customer discounts
1447 will be applied to the assortment items.
1449 Once the assortment items have been added they may be modified or deleted, just
1450 as if they had been added manually, the individual assortment items aren't
1451 linked to the assortment or the other assortment items in any way.
1459 =item C<action_add_part>
1461 =item C<action_add_service>
1463 =item C<action_add_assembly>
1465 =item C<action_add_assortment>
1467 =item C<action_add PART_TYPE>
1469 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1470 parameter part_type as an action. Example:
1472 controller.pl?action=Part/add&part_type=service
1474 =item C<action_add_from_record>
1476 When adding new items to records they can be created on the fly if the entered
1477 partnumber or description doesn't exist yet. After being asked what part type
1478 the new item should have the user is redirected to the correct edit page.
1480 Depending on whether the item was added from a sales or a purchase record, only
1481 the relevant part classifications should be selectable for new item, so this
1482 parameter is passed on via a hidden parts_classification_type in the new_item
1485 =item C<action_save>
1487 Saves the current part and then reloads the edit page for the part.
1489 =item C<action_use_as_new>
1491 Takes the information from the current part, plus any modifications made on the
1492 page, and creates a new edit page that is ready to be saved. The partnumber is
1493 set empty, so a new partnumber from the number range will be used if the user
1494 doesn't enter one manually.
1496 Unsaved changes to the original part aren't updated.
1498 The part type cannot be changed in this way.
1500 =item C<action_delete>
1502 Deletes the current part and then redirects to the main page, there is no
1505 The delete button only appears if the part is 'orphaned', according to
1506 SL::DB::Part orphaned.
1508 The part can't be deleted if it appears in invoices, orders, delivery orders,
1509 the inventory, or is part of an assembly or assortment.
1511 If the part is deleted its relations prices, makdemodel, assembly,
1512 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1514 Before this controller items that appeared in inventory didn't count as
1515 orphaned and could be deleted and the inventory entries were also deleted, this
1516 "feature" hasn't been implemented.
1518 =item C<action_edit part.id>
1520 Load and display a part for editing.
1522 controller.pl?action=Part/edit&part.id=12345
1524 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1528 =head1 BUTTON ACTIONS
1534 Opens a popup displaying all the history entries. Once a new history controller
1535 is written the button could link there instead, with the part already selected.
1543 =item C<action_update_item_totals>
1545 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1546 amount of an item changes. The sum of all sellprices and lastcosts is
1547 calculated and the totals updated. Uses C<recalc_item_totals>.
1549 =item C<action_add_assortment_item>
1551 Adds a new assortment item from a part picker seleciton to the assortment item list
1553 If the item already exists in the assortment the item isn't added and a Flash
1556 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1557 after adding each new item, add the new object to the item objects that were
1558 already parsed, calculate totals via a dummy part then update the row and the
1561 =item C<action_add_assembly_item>
1563 Adds a new assembly item from a part picker seleciton to the assembly item list
1565 If the item already exists in the assembly a flash info is generated, but the
1568 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1569 after adding each new item, add the new object to the item objects that were
1570 already parsed, calculate totals via a dummy part then update the row and the
1573 =item C<action_add_multi_assortment_items>
1575 Parses the items to be added from the form generated by the multi input and
1576 appends the html of the tr-rows to the assortment item table. Afterwards all
1577 assortment items are renumbered and the sums recalculated via
1578 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1580 =item C<action_add_multi_assembly_items>
1582 Parses the items to be added from the form generated by the multi input and
1583 appends the html of the tr-rows to the assembly item table. Afterwards all
1584 assembly items are renumbered and the sums recalculated via
1585 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1587 =item C<action_show_multi_items_dialog>
1589 =item C<action_multi_items_update_result>
1591 =item C<action_add_makemodel_row>
1593 Add a new makemodel row with the vendor that was selected via the vendor
1596 Checks the already existing makemodels and warns if a row with that vendor
1597 already exists. Currently it is possible to have duplicate vendor rows.
1599 =item C<action_reorder_items>
1601 Sorts the item table for assembly or assortment items.
1603 =item C<action_warehouse_changed>
1607 =head1 ACTIONS part picker
1611 =item C<action_ajax_autocomplete>
1613 =item C<action_test_page>
1615 =item C<action_part_picker_search>
1617 =item C<action_part_picker_result>
1619 =item C<action_show>
1629 Calls some simple checks that test the submitted $::form for obvious errors.
1630 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1632 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1633 some cases extra actions are taken, e.g. if the part description is missing the
1634 basic data tab is selected and the description input field is focussed.
1640 =item C<form_check_part_description_exists>
1642 =item C<form_check_assortment_items_exist>
1644 =item C<form_check_assortment_items_unique>
1646 =item C<form_check_assembly_items_exist>
1648 =item C<form_check_partnumber_is_unique>
1652 =head1 HELPER FUNCTIONS
1658 When submitting the form for saving, parses the transmitted form. Expects the
1662 $::form->{makemodels}
1663 $::form->{translations}
1665 $::form->{assemblies}
1666 $::form->{assortments}
1668 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1670 =item C<recalc_item_totals %params>
1672 Helper function for calculating the total lastcost and sellprice for assemblies
1673 or assortments according to their items, which are parsed from the current
1676 Is called whenever the qty of an item is changed or items are deleted.
1680 * part_type : 'assortment' or 'assembly' (mandatory)
1682 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1684 Depending on the price_type the lastcost sum or sellprice sum is returned.
1686 Doesn't work for recursive items.
1690 =head1 GET SET INITS
1692 There are get_set_inits for
1700 which parse $::form and automatically create an array of objects.
1702 These inits are used during saving and each time a new element is added.
1706 =item C<init_makemodels>
1708 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1709 $self->part->makemodels, ready to be saved.
1711 Used for saving parts and adding new makemodel rows.
1713 =item C<parse_add_items_to_objects PART_TYPE>
1715 Parses the resulting form from either the part-picker submit or the multi-item
1716 submit, and creates an arrayref of assortment_item or assembly objects, that
1717 can be rendered via C<render_assortment_items_to_html> or
1718 C<render_assembly_items_to_html>.
1720 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1721 Optional param: position (used for numbering and listrow class)
1723 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1725 Takes an array_ref of assortment_items, and generates tables rows ready for
1726 adding to the assortment table. Is used when a part is loaded, or whenever new
1727 assortment items are added.
1729 =item C<parse_form_makemodels>
1731 Makemodels can't just be overwritten, because of the field "lastupdate", that
1732 remembers when the lastcost for that vendor changed the last time.
1734 So the original values are cloned and remembered, so we can compare if lastcost
1735 was changed in $::form, and keep or update lastupdate.
1737 lastcost isn't updated until the first time it was saved with a value, until
1740 Also a boolean "makemodel" needs to be written in parts, depending on whether
1741 makemodel entries exist or not.
1743 We still need init_makemodels for when we open the part for editing.
1753 It should be possible to jump to the edit page in a specific tab
1757 Support callbacks, e.g. creating a new part from within an order, and jumping
1758 back to the order again afterwards.
1762 Support units when adding assembly items or assortment items. Currently the
1763 default unit of the item is always used.
1767 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1768 consists of other assemblies.
1774 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>