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(sub { $::auth->assert('developer') },
45 only => [ qw(test_page) ]);
47 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
49 # actions for editing parts
52 my ($self, %params) = @_;
54 $self->part( SL::DB::Part->new_part );
58 sub action_add_service {
59 my ($self, %params) = @_;
61 $self->part( SL::DB::Part->new_service );
65 sub action_add_assembly {
66 my ($self, %params) = @_;
68 $self->part( SL::DB::Part->new_assembly );
72 sub action_add_assortment {
73 my ($self, %params) = @_;
75 $self->part( SL::DB::Part->new_assortment );
79 sub action_add_from_record {
82 check_has_valid_part_type($::form->{part}{part_type});
84 die 'parts_classification_type must be "sales" or "purchases"'
85 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
94 check_has_valid_part_type($::form->{part_type});
96 $self->action_add_part if $::form->{part_type} eq 'part';
97 $self->action_add_service if $::form->{part_type} eq 'service';
98 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
99 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
103 my ($self, %params) = @_;
105 # checks that depend only on submitted $::form
106 $self->check_form or return $self->js->render;
108 my $is_new = !$self->part->id; # $ part gets loaded here
110 # check that the part hasn't been modified
112 $self->check_part_not_modified or
113 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;
117 && $::form->{part}{partnumber}
118 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
120 return $self->js->error(t8('The partnumber is already being used'))->render;
125 my @errors = $self->part->validate;
126 return $self->js->error(@errors)->render if @errors;
128 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
129 $self->part->db->with_transaction(sub {
131 $self->part->save(cascade => 1);
133 SL::DB::History->new(
134 trans_id => $self->part->id,
135 snumbers => 'partnumber_' . $self->part->partnumber,
136 employee_id => SL::DB::Manager::Employee->current->id,
141 CVar->save_custom_variables(
142 dbh => $self->part->db->dbh,
144 trans_id => $self->part->id,
145 variables => $::form, # $::form->{cvar} would be nicer
150 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
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_and_purchase_order {
166 delete $::form->{previousform};
167 $::form->{callback} = $self->url_for(
168 controller => 'Order',
169 action => 'return_from_create_part',
170 type => 'purchase_order',
173 $self->_run_action('save');
179 if ( $::form->{callback} ) {
180 $self->redirect_to($::form->unescape($::form->{callback}));
187 my $db = $self->part->db; # $self->part has a get_set_init on $::form
189 my $partnumber = $self->part->partnumber; # remember for history log
194 # delete part, together with relationships that don't already
195 # have an ON DELETE CASCADE, e.g. makemodel and translation.
196 $self->part->delete(cascade => 1);
198 SL::DB::History->new(
199 trans_id => $self->part->id,
200 snumbers => 'partnumber_' . $partnumber,
201 employee_id => SL::DB::Manager::Employee->current->id,
203 addition => 'DELETED',
206 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
208 flash_later('info', t8('The item has been deleted.'));
209 if ( $::form->{callback} ) {
210 $self->redirect_to($::form->unescape($::form->{callback}));
212 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
216 sub action_use_as_new {
217 my ($self, %params) = @_;
219 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
220 $::form->{oldpartnumber} = $oldpart->partnumber;
222 $self->part($oldpart->clone_and_reset_deep);
224 $self->part->partnumber(undef);
230 my ($self, %params) = @_;
236 my ($self, %params) = @_;
238 $self->_set_javascript;
239 $self->_setup_form_action_bar;
241 my (%assortment_vars, %assembly_vars);
242 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
243 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
245 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
247 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
248 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
249 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
250 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
253 my %title_hash = ( part => t8('Edit Part'),
254 assembly => t8('Edit Assembly'),
255 service => t8('Edit Service'),
256 assortment => t8('Edit Assortment'),
259 $self->part->prices([]) unless $self->part->prices;
260 $self->part->translations([]) unless $self->part->translations;
264 title => $title_hash{$self->part->part_type},
267 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
268 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
269 oldpartnumber => $::form->{oldpartnumber},
270 old_id => $::form->{old_id},
278 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
279 $_[0]->render('part/history', { layout => 0 },
280 history_entries => $history_entries);
283 sub action_inventory {
286 $::auth->assert('warehouse_contents');
288 $self->stock_amounts($self->part->get_simple_stock_sql);
289 $self->journal($self->part->get_mini_journal);
291 $_[0]->render('part/_inventory_data', { layout => 0 });
294 sub action_update_item_totals {
297 my $part_type = $::form->{part_type};
298 die unless $part_type =~ /^(assortment|assembly)$/;
300 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
301 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
302 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
304 my $sum_diff = $sellprice_sum-$lastcost_sum;
307 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
308 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
309 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
310 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
311 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
312 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
313 ->no_flash_clear->render();
316 sub action_add_multi_assortment_items {
319 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
320 my $html = $self->render_assortment_items_to_html($item_objects);
322 $self->js->run('kivi.Part.close_picker_dialogs')
323 ->append('#assortment_rows', $html)
324 ->run('kivi.Part.renumber_positions')
325 ->run('kivi.Part.assortment_recalc')
329 sub action_add_multi_assembly_items {
332 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
334 foreach my $item (@{$item_objects}) {
335 my $errstr = validate_assembly($item->part,$self->part);
336 $self->js->flash('error',$errstr) if $errstr;
337 push (@checked_objects,$item) unless $errstr;
340 my $html = $self->render_assembly_items_to_html(\@checked_objects);
342 $self->js->run('kivi.Part.close_picker_dialogs')
343 ->append('#assembly_rows', $html)
344 ->run('kivi.Part.renumber_positions')
345 ->run('kivi.Part.assembly_recalc')
349 sub action_add_assortment_item {
350 my ($self, %params) = @_;
352 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
354 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
356 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
357 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
358 return $self->js->flash('error', t8("This part has already been added."))->render;
361 my $number_of_items = scalar @{$self->assortment_items};
362 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
363 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
365 push(@{$self->assortment_items}, @{$item_objects});
366 my $part = SL::DB::Part->new(part_type => 'assortment');
367 $part->assortment_items(@{$self->assortment_items});
368 my $items_sellprice_sum = $part->items_sellprice_sum;
369 my $items_lastcost_sum = $part->items_lastcost_sum;
370 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
373 ->append('#assortment_rows' , $html) # append in tbody
374 ->val('.add_assortment_item_input' , '')
375 ->run('kivi.Part.focus_last_assortment_input')
376 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
377 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
378 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
379 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
380 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
384 sub action_add_assembly_item {
387 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
389 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
391 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
393 my $duplicate_warning = 0; # duplicates are allowed, just warn
394 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
395 $duplicate_warning++;
398 my $number_of_items = scalar @{$self->assembly_items};
399 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
401 foreach my $item (@{$item_objects}) {
402 my $errstr = validate_assembly($item->part,$self->part);
403 return $self->js->flash('error',$errstr)->render if $errstr;
408 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
410 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
412 push(@{$self->assembly_items}, @{$item_objects});
413 my $part = SL::DB::Part->new(part_type => 'assembly');
414 $part->assemblies(@{$self->assembly_items});
415 my $items_sellprice_sum = $part->items_sellprice_sum;
416 my $items_lastcost_sum = $part->items_lastcost_sum;
417 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
418 my $items_weight_sum = $part->items_weight_sum;
421 ->append('#assembly_rows', $html) # append in tbody
422 ->val('.add_assembly_item_input' , '')
423 ->run('kivi.Part.focus_last_assembly_input')
424 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
425 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
426 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
427 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
428 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
429 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
433 sub action_show_multi_items_dialog {
436 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
437 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
438 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
440 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
441 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
442 search_term => $search_term
446 sub action_multi_items_update_result {
447 my $max_count = $::form->{limit};
449 my $count = $_[0]->multi_items_models->count;
452 my $text = escape($::locale->text('No results.'));
453 $_[0]->render($text, { layout => 0 });
454 } elsif ($max_count && $count > $max_count) {
455 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
456 $_[0]->render($text, { layout => 0 });
458 my $multi_items = $_[0]->multi_items_models->get;
459 $_[0]->render('part/_multi_items_result', { layout => 0 },
460 multi_items => $multi_items);
464 sub action_add_makemodel_row {
467 my $vendor_id = $::form->{add_makemodel};
469 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
470 return $self->js->error(t8("No vendor selected or found!"))->render;
472 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
473 $self->js->flash('info', t8("This vendor has already been added."));
476 my $position = scalar @{$self->makemodels} + 1;
478 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
482 sortorder => $position,
483 ) or die "Can't create MakeModel object";
485 my $row_as_html = $self->p->render('part/_makemodel_row',
487 listrow => $position % 2 ? 0 : 1,
490 # after selection focus on the model field in the row that was just added
492 ->append('#makemodel_rows', $row_as_html) # append in tbody
493 ->val('.add_makemodel_input', '')
494 ->run('kivi.Part.focus_last_makemodel_input')
498 sub action_add_customerprice_row {
501 my $customer_id = $::form->{add_customerprice};
503 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
504 or return $self->js->error(t8("No customer selected or found!"))->render;
506 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
507 $self->js->flash('info', t8("This customer has already been added."));
510 my $position = scalar @{ $self->customerprices } + 1;
512 my $cu = SL::DB::PartCustomerPrice->new(
513 customer_id => $customer->id,
514 customer_partnumber => '',
516 sortorder => $position,
517 ) or die "Can't create Customerprice object";
519 my $row_as_html = $self->p->render(
520 'part/_customerprice_row',
521 customerprice => $cu,
522 listrow => $position % 2 ? 0
526 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
527 ->val('.add_customerprice_input', '')
528 ->run('kivi.Part.focus_last_customerprice_input')->render;
531 sub action_reorder_items {
534 my $part_type = $::form->{part_type};
537 partnumber => sub { $_[0]->part->partnumber },
538 description => sub { $_[0]->part->description },
539 qty => sub { $_[0]->qty },
540 sellprice => sub { $_[0]->part->sellprice },
541 lastcost => sub { $_[0]->part->lastcost },
542 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
545 my $method = $sort_keys{$::form->{order_by}};
548 if ($part_type eq 'assortment') {
549 @items = @{ $self->assortment_items };
551 @items = @{ $self->assembly_items };
554 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
555 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
556 if ($::form->{sort_dir}) {
557 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
559 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
562 if ($::form->{sort_dir}) {
563 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
565 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
569 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
572 sub action_warehouse_changed {
575 if ($::form->{warehouse_id} ) {
576 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
577 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
579 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
580 $self->bin($self->warehouse->bins_sorted->[0]);
582 ->html('#bin', $self->build_bin_select)
583 ->focus('#part_bin_id');
584 return $self->js->render;
588 # no warehouse was selected, empty the bin field and reset the id
590 ->val('#part_bin_id', undef)
593 return $self->js->render;
596 sub action_ajax_autocomplete {
597 my ($self, %params) = @_;
599 # if someone types something, and hits enter, assume he entered the full name.
600 # if something matches, treat that as sole match
601 # since we need a second get models instance with different filters for that,
602 # we only modify the original filter temporarily in place
603 if ($::form->{prefer_exact}) {
604 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
605 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
606 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
608 my $exact_models = SL::Controller::Helper::GetModels->new(
611 paginated => { per_page => 2 },
612 with_objects => [ qw(unit_obj classification) ],
615 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
616 $self->parts($exact_matches);
622 value => $_->displayable_name,
623 label => $_->displayable_name,
625 partnumber => $_->partnumber,
626 description => $_->description,
628 part_type => $_->part_type,
630 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
632 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
634 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
637 sub action_test_page {
638 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
641 sub action_part_picker_search {
644 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
645 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
646 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
648 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
651 sub action_part_picker_result {
652 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
658 if ($::request->type eq 'json') {
663 $part_hash = $self->part->as_tree;
664 $part_hash->{cvars} = $self->part->cvar_as_hashref;
667 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
672 sub validate_add_items {
673 scalar @{$::form->{add_items}};
676 sub prepare_assortment_render_vars {
679 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
680 items_lastcost_sum => $self->part->items_lastcost_sum,
681 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
683 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
688 sub prepare_assembly_render_vars {
691 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
693 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
694 items_lastcost_sum => $self->part->items_lastcost_sum,
695 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
697 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
705 check_has_valid_part_type($self->part->part_type);
707 $self->_set_javascript;
708 $self->_setup_form_action_bar;
710 my %title_hash = ( part => t8('Add Part'),
711 assembly => t8('Add Assembly'),
712 service => t8('Add Service'),
713 assortment => t8('Add Assortment'),
718 title => $title_hash{$self->part->part_type},
723 sub _set_javascript {
725 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart kivi.Validator);
726 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
729 sub recalc_item_totals {
730 my ($self, %params) = @_;
732 if ( $params{part_type} eq 'assortment' ) {
733 return 0 unless scalar @{$self->assortment_items};
734 } elsif ( $params{part_type} eq 'assembly' ) {
735 return 0 unless scalar @{$self->assembly_items};
737 carp "can only calculate sum for assortments and assemblies";
740 my $part = SL::DB::Part->new(part_type => $params{part_type});
741 if ( $part->is_assortment ) {
742 $part->assortment_items( @{$self->assortment_items} );
743 if ( $params{price_type} eq 'lastcost' ) {
744 return $part->items_lastcost_sum;
746 if ( $params{pricegroup_id} ) {
747 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
749 return $part->items_sellprice_sum;
752 } elsif ( $part->is_assembly ) {
753 $part->assemblies( @{$self->assembly_items} );
754 if ( $params{price_type} eq 'weight' ) {
755 return $part->items_weight_sum;
756 } elsif ( $params{price_type} eq 'lastcost' ) {
757 return $part->items_lastcost_sum;
759 return $part->items_sellprice_sum;
764 sub check_part_not_modified {
767 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
774 my $is_new = !$self->part->id;
776 my $params = delete($::form->{part}) || { };
778 delete $params->{id};
779 $self->part->assign_attributes(%{ $params});
780 $self->part->bin_id(undef) unless $self->part->warehouse_id;
782 $self->normalize_text_blocks;
784 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
785 # will be the case for used assortments when saving, or when a used assortment
787 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
788 $self->part->assortment_items([]);
789 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
792 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
793 $self->part->assemblies([]); # completely rewrite assortments each time
794 $self->part->add_assemblies( @{ $self->assembly_items } );
797 $self->part->translations([]);
798 $self->parse_form_translations;
800 $self->part->prices([]);
801 $self->parse_form_prices;
803 $self->parse_form_customerprices;
804 $self->parse_form_makemodels;
807 sub parse_form_prices {
809 # only save prices > 0
810 my $prices = delete($::form->{prices}) || [];
811 foreach my $price ( @{$prices} ) {
812 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
813 next unless $sellprice > 0; # skip negative prices as well
814 my $p = SL::DB::Price->new(parts_id => $self->part->id,
815 pricegroup_id => $price->{pricegroup_id},
818 $self->part->add_prices($p);
822 sub parse_form_translations {
824 # don't add empty translations
825 my $translations = delete($::form->{translations}) || [];
826 foreach my $translation ( @{$translations} ) {
827 next unless $translation->{translation};
828 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
829 $self->part->add_translations( $translation );
833 sub parse_form_makemodels {
837 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
838 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
841 $self->part->makemodels([]);
844 my $makemodels = delete($::form->{makemodels}) || [];
845 foreach my $makemodel ( @{$makemodels} ) {
846 next unless $makemodel->{make};
848 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
850 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
851 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
853 make => $makemodel->{make},
854 model => $makemodel->{model} || '',
855 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
856 sortorder => $position,
858 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
859 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
860 # don't change lastupdate
861 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
862 # new makemodel, no lastcost entered, leave lastupdate empty
863 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
864 # lastcost hasn't changed, use original lastupdate
865 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
867 $mm->lastupdate(DateTime->now);
869 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
870 $self->part->add_makemodels($mm);
874 sub parse_form_customerprices {
877 my $customerprices_map;
878 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
879 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
882 $self->part->customerprices([]);
885 my $customerprices = delete($::form->{customerprices}) || [];
886 foreach my $customerprice ( @{$customerprices} ) {
887 next unless $customerprice->{customer_id};
889 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
891 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
892 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
894 customer_id => $customerprice->{customer_id},
895 customer_partnumber => $customerprice->{customer_partnumber} || '',
896 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
897 sortorder => $position,
899 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
900 # lastupdate isn't set, original price is 0 and new lastcost is 0
901 # don't change lastupdate
902 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
903 # new customerprice, no lastcost entered, leave lastupdate empty
904 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
905 # price hasn't changed, use original lastupdate
906 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
908 $cu->lastupdate(DateTime->now);
910 $self->part->add_customerprices($cu);
914 sub build_bin_select {
915 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ],
916 title_key => 'description',
917 default => $_[0]->bin->id,
922 # get_set_inits for partpicker
925 if ($::form->{no_paginate}) {
926 $_[0]->models->disable_plugin('paginated');
932 # get_set_inits for part controller
936 # used by edit, save, delete and add
938 if ( $::form->{part}{id} ) {
939 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
940 } elsif ( $::form->{id} ) {
941 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
943 die "part_type missing" unless $::form->{part}{part_type};
944 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
950 return $self->part->orphaned;
956 SL::Controller::Helper::GetModels->new(
963 partnumber => t8('Partnumber'),
964 description => t8('Description'),
966 with_objects => [ qw(unit_obj classification) ],
975 sub init_assortment_items {
976 # this init is used while saving and whenever assortments change dynamically
980 my $assortment_items = delete($::form->{assortment_items}) || [];
981 foreach my $assortment_item ( @{$assortment_items} ) {
982 next unless $assortment_item->{parts_id};
984 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
985 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
986 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
987 charge => $assortment_item->{charge},
988 unit => $assortment_item->{unit} || $part->unit,
989 position => $position,
997 sub init_makemodels {
1001 my @makemodel_array = ();
1002 my $makemodels = delete($::form->{makemodels}) || [];
1004 foreach my $makemodel ( @{$makemodels} ) {
1005 next unless $makemodel->{make};
1007 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
1008 id => $makemodel->{id},
1009 make => $makemodel->{make},
1010 model => $makemodel->{model} || '',
1011 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
1012 sortorder => $position,
1013 ) or die "Can't create mm";
1014 # $mm->id($makemodel->{id}) if $makemodel->{id};
1015 push(@makemodel_array, $mm);
1017 return \@makemodel_array;
1020 sub init_customerprices {
1024 my @customerprice_array = ();
1025 my $customerprices = delete($::form->{customerprices}) || [];
1027 foreach my $customerprice ( @{$customerprices} ) {
1028 next unless $customerprice->{customer_id};
1030 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1031 id => $customerprice->{id},
1032 customer_partnumber => $customerprice->{customer_partnumber},
1033 customer_id => $customerprice->{customer_id} || '',
1034 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1035 sortorder => $position,
1036 ) or die "Can't create cu";
1037 # $cu->id($customerprice->{id}) if $customerprice->{id};
1038 push(@customerprice_array, $cu);
1040 return \@customerprice_array;
1043 sub init_assembly_items {
1047 my $assembly_items = delete($::form->{assembly_items}) || [];
1048 foreach my $assembly_item ( @{$assembly_items} ) {
1049 next unless $assembly_item->{parts_id};
1051 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1052 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1053 bom => $assembly_item->{bom},
1054 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1055 position => $position,
1062 sub init_all_warehouses {
1064 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1067 sub init_all_languages {
1068 SL::DB::Manager::Language->get_all_sorted;
1071 sub init_all_partsgroups {
1073 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1076 sub init_all_buchungsgruppen {
1078 if ( $self->part->orphaned ) {
1079 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
1081 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1085 sub init_shops_not_assigned {
1088 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1089 if ( @used_shop_ids ) {
1090 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1093 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1097 sub init_all_units {
1099 if ( $self->part->orphaned ) {
1100 return SL::DB::Manager::Unit->get_all_sorted;
1102 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1106 sub init_all_payment_terms {
1108 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1111 sub init_all_price_factors {
1112 SL::DB::Manager::PriceFactor->get_all_sorted;
1115 sub init_all_pricegroups {
1116 SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]);
1119 # model used to filter/display the parts in the multi-items dialog
1120 sub init_multi_items_models {
1121 SL::Controller::Helper::GetModels->new(
1122 controller => $_[0],
1124 with_objects => [ qw(unit_obj partsgroup classification) ],
1125 disable_plugin => 'paginated',
1126 source => $::form->{multi_items},
1132 partnumber => t8('Partnumber'),
1133 description => t8('Description')}
1137 sub init_parts_classification_filter {
1138 return [] unless $::form->{parts_classification_type};
1140 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1141 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1143 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1146 # simple checks to run on $::form before saving
1148 sub form_check_part_description_exists {
1151 return 1 if $::form->{part}{description};
1153 $self->js->flash('error', t8('Part Description missing!'))
1154 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1155 ->focus('#part_description');
1159 sub form_check_assortment_items_exist {
1162 return 1 unless $::form->{part}{part_type} eq 'assortment';
1163 # skip item check for existing assortments that have been used
1164 return 1 if ($self->part->id and !$self->part->orphaned);
1166 # new or orphaned parts must have items in $::form->{assortment_items}
1167 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1168 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1169 ->focus('#add_assortment_item_name')
1170 ->flash('error', t8('The assortment doesn\'t have any items.'));
1176 sub form_check_assortment_items_unique {
1179 return 1 unless $::form->{part}{part_type} eq 'assortment';
1181 my %duplicate_elements;
1183 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1184 $duplicate_elements{$_}++ if $count{$_}++;
1187 if ( keys %duplicate_elements ) {
1188 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1189 ->flash('error', t8('There are duplicate assortment items'));
1195 sub form_check_assembly_items_exist {
1198 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1200 # skip item check for existing assembly that have been used
1201 return 1 if ($self->part->id and !$self->part->orphaned);
1203 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1204 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1205 ->focus('#add_assembly_item_name')
1206 ->flash('error', t8('The assembly doesn\'t have any items.'));
1212 sub form_check_partnumber_is_unique {
1215 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1216 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1218 $self->js->flash('error', t8('The partnumber already exists!'))
1219 ->focus('#part_description');
1226 # general checking functions
1229 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1235 $self->form_check_part_description_exists || return 0;
1236 $self->form_check_assortment_items_exist || return 0;
1237 $self->form_check_assortment_items_unique || return 0;
1238 $self->form_check_assembly_items_exist || return 0;
1239 $self->form_check_partnumber_is_unique || return 0;
1244 sub check_has_valid_part_type {
1245 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1249 sub normalize_text_blocks {
1252 # check if feature is enabled (select normalize_part_descriptions from defaults)
1253 return unless ($::instance_conf->get_normalize_part_descriptions);
1256 foreach (qw(description)) {
1257 $self->part->{$_} =~ s/\s+$//s;
1258 $self->part->{$_} =~ s/^\s+//s;
1259 $self->part->{$_} =~ s/ {2,}/ /g;
1261 # html block (caveat: can be circumvented by using bold or italics)
1262 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1263 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1267 sub render_assortment_items_to_html {
1268 my ($self, $assortment_items, $number_of_items) = @_;
1270 my $position = $number_of_items + 1;
1272 foreach my $ai (@$assortment_items) {
1273 $html .= $self->p->render('part/_assortment_row',
1274 PART => $self->part,
1275 orphaned => $self->orphaned,
1277 listrow => $position % 2 ? 1 : 0,
1278 position => $position, # for legacy assemblies
1285 sub render_assembly_items_to_html {
1286 my ($self, $assembly_items, $number_of_items) = @_;
1288 my $position = $number_of_items + 1;
1290 foreach my $ai (@{$assembly_items}) {
1291 $html .= $self->p->render('part/_assembly_row',
1292 PART => $self->part,
1293 orphaned => $self->orphaned,
1295 listrow => $position % 2 ? 1 : 0,
1296 position => $position, # for legacy assemblies
1303 sub parse_add_items_to_objects {
1304 my ($self, %params) = @_;
1305 my $part_type = $params{part_type};
1306 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1307 my $position = $params{position} || 1;
1309 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1312 foreach my $item ( @add_items ) {
1313 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1315 if ( $part_type eq 'assortment' ) {
1316 $ai = SL::DB::AssortmentItem->new(part => $part,
1317 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1318 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1319 position => $position,
1320 ) or die "Can't create AssortmentItem from item";
1321 } elsif ( $part_type eq 'assembly' ) {
1322 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1323 # id => $self->assembly->id, # will be set on save
1324 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1325 bom => 0, # default when adding: no bom
1326 position => $position,
1329 die "part_type must be assortment or assembly";
1331 push(@item_objects, $ai);
1335 return \@item_objects;
1338 sub _setup_form_action_bar {
1341 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1342 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1344 for my $bar ($::request->layout->get('actionbar')) {
1349 call => [ 'kivi.Part.save' ],
1350 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1351 checks => ['kivi.validate_form'],
1355 call => [ 'kivi.Part.use_as_new' ],
1356 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1357 : !$may_edit ? t8('You do not have the permissions to access this function.')
1360 ], # end of combobox "Save"
1363 action => [ t8('Workflow') ],
1365 t8('Save and Purchase Order'),
1366 submit => [ '#ic', { action => "Part/save_and_purchase_order" } ],
1367 checks => ['kivi.validate_form'],
1368 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1369 : !$may_edit ? t8('You do not have the permissions to access this function.')
1370 : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.')
1372 only_if => !$::form->{inline_create},
1378 submit => [ '#ic', { action => "Part/abort" } ],
1379 only_if => !!$::form->{inline_create},
1384 call => [ 'kivi.Part.delete' ],
1385 confirm => t8('Do you really want to delete this object?'),
1386 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1387 : !$may_edit ? t8('You do not have the permissions to access this function.')
1388 : !$self->part->orphaned ? t8('This object has already been used.')
1389 : $used_in_pricerules ? t8('This object is used in price rules.')
1397 call => [ 'kivi.Part.open_history_popup' ],
1398 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1399 : !$may_edit ? t8('You do not have the permissions to access this function.')
1414 SL::Controller::Part - Part CRUD controller
1418 Controller for adding/editing/saving/deleting parts.
1420 All the relations are loaded at once and saving the part, adding a history
1421 entry and saving CVars happens inside one transaction. When saving the old
1422 relations are deleted and written as new to the database.
1424 Relations for parts:
1432 =item assembly items
1434 =item assortment items
1442 There are 4 different part types:
1448 The "default" part type.
1450 inventory_accno_id is set.
1454 Services can't be stocked.
1456 inventory_accno_id isn't set.
1460 Assemblies consist of other parts, services, assemblies or assortments. They
1461 aren't meant to be bought, only sold. To add assemblies to stock you typically
1462 have to make them, which reduces the stock by its respective components. Once
1463 an assembly item has been created there is currently no way to "disassemble" it
1464 again. An assembly item can appear several times in one assembly. An assmbly is
1465 sold as one item with a defined sellprice and lastcost. If the component prices
1466 change the assortment price remains the same. The assembly items may be printed
1467 in a record if the item's "bom" is set.
1471 Similar to assembly, but each assortment item may only appear once per
1472 assortment. When selling an assortment the assortment items are added to the
1473 record together with the assortment, which is added with sellprice 0.
1475 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1476 determined by the sum of the current assortment item prices when the assortment
1477 is added to a record. This also means that price rules and customer discounts
1478 will be applied to the assortment items.
1480 Once the assortment items have been added they may be modified or deleted, just
1481 as if they had been added manually, the individual assortment items aren't
1482 linked to the assortment or the other assortment items in any way.
1490 =item C<action_add_part>
1492 =item C<action_add_service>
1494 =item C<action_add_assembly>
1496 =item C<action_add_assortment>
1498 =item C<action_add PART_TYPE>
1500 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1501 parameter part_type as an action. Example:
1503 controller.pl?action=Part/add&part_type=service
1505 =item C<action_add_from_record>
1507 When adding new items to records they can be created on the fly if the entered
1508 partnumber or description doesn't exist yet. After being asked what part type
1509 the new item should have the user is redirected to the correct edit page.
1511 Depending on whether the item was added from a sales or a purchase record, only
1512 the relevant part classifications should be selectable for new item, so this
1513 parameter is passed on via a hidden parts_classification_type in the new_item
1516 =item C<action_save>
1518 Saves the current part and then reloads the edit page for the part.
1520 =item C<action_use_as_new>
1522 Takes the information from the current part, plus any modifications made on the
1523 page, and creates a new edit page that is ready to be saved. The partnumber is
1524 set empty, so a new partnumber from the number range will be used if the user
1525 doesn't enter one manually.
1527 Unsaved changes to the original part aren't updated.
1529 The part type cannot be changed in this way.
1531 =item C<action_delete>
1533 Deletes the current part and then redirects to the main page, there is no
1536 The delete button only appears if the part is 'orphaned', according to
1537 SL::DB::Part orphaned.
1539 The part can't be deleted if it appears in invoices, orders, delivery orders,
1540 the inventory, or is part of an assembly or assortment.
1542 If the part is deleted its relations prices, makdemodel, assembly,
1543 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1545 Before this controller items that appeared in inventory didn't count as
1546 orphaned and could be deleted and the inventory entries were also deleted, this
1547 "feature" hasn't been implemented.
1549 =item C<action_edit part.id>
1551 Load and display a part for editing.
1553 controller.pl?action=Part/edit&part.id=12345
1555 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1559 =head1 BUTTON ACTIONS
1565 Opens a popup displaying all the history entries. Once a new history controller
1566 is written the button could link there instead, with the part already selected.
1574 =item C<action_update_item_totals>
1576 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1577 amount of an item changes. The sum of all sellprices and lastcosts is
1578 calculated and the totals updated. Uses C<recalc_item_totals>.
1580 =item C<action_add_assortment_item>
1582 Adds a new assortment item from a part picker seleciton to the assortment item list
1584 If the item already exists in the assortment the item isn't added and a Flash
1587 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1588 after adding each new item, add the new object to the item objects that were
1589 already parsed, calculate totals via a dummy part then update the row and the
1592 =item C<action_add_assembly_item>
1594 Adds a new assembly item from a part picker seleciton to the assembly item list
1596 If the item already exists in the assembly a flash info is generated, but the
1599 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1600 after adding each new item, add the new object to the item objects that were
1601 already parsed, calculate totals via a dummy part then update the row and the
1604 =item C<action_add_multi_assortment_items>
1606 Parses the items to be added from the form generated by the multi input and
1607 appends the html of the tr-rows to the assortment item table. Afterwards all
1608 assortment items are renumbered and the sums recalculated via
1609 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1611 =item C<action_add_multi_assembly_items>
1613 Parses the items to be added from the form generated by the multi input and
1614 appends the html of the tr-rows to the assembly item table. Afterwards all
1615 assembly items are renumbered and the sums recalculated via
1616 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1618 =item C<action_show_multi_items_dialog>
1620 =item C<action_multi_items_update_result>
1622 =item C<action_add_makemodel_row>
1624 Add a new makemodel row with the vendor that was selected via the vendor
1627 Checks the already existing makemodels and warns if a row with that vendor
1628 already exists. Currently it is possible to have duplicate vendor rows.
1630 =item C<action_reorder_items>
1632 Sorts the item table for assembly or assortment items.
1634 =item C<action_warehouse_changed>
1638 =head1 ACTIONS part picker
1642 =item C<action_ajax_autocomplete>
1644 =item C<action_test_page>
1646 =item C<action_part_picker_search>
1648 =item C<action_part_picker_result>
1650 =item C<action_show>
1660 Calls some simple checks that test the submitted $::form for obvious errors.
1661 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1663 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1664 some cases extra actions are taken, e.g. if the part description is missing the
1665 basic data tab is selected and the description input field is focussed.
1671 =item C<form_check_part_description_exists>
1673 =item C<form_check_assortment_items_exist>
1675 =item C<form_check_assortment_items_unique>
1677 =item C<form_check_assembly_items_exist>
1679 =item C<form_check_partnumber_is_unique>
1683 =head1 HELPER FUNCTIONS
1689 When submitting the form for saving, parses the transmitted form. Expects the
1693 $::form->{makemodels}
1694 $::form->{translations}
1696 $::form->{assemblies}
1697 $::form->{assortments}
1699 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1701 =item C<recalc_item_totals %params>
1703 Helper function for calculating the total lastcost and sellprice for assemblies
1704 or assortments according to their items, which are parsed from the current
1707 Is called whenever the qty of an item is changed or items are deleted.
1711 * part_type : 'assortment' or 'assembly' (mandatory)
1713 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1715 Depending on the price_type the lastcost sum or sellprice sum is returned.
1717 Doesn't work for recursive items.
1721 =head1 GET SET INITS
1723 There are get_set_inits for
1731 which parse $::form and automatically create an array of objects.
1733 These inits are used during saving and each time a new element is added.
1737 =item C<init_makemodels>
1739 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1740 $self->part->makemodels, ready to be saved.
1742 Used for saving parts and adding new makemodel rows.
1744 =item C<parse_add_items_to_objects PART_TYPE>
1746 Parses the resulting form from either the part-picker submit or the multi-item
1747 submit, and creates an arrayref of assortment_item or assembly objects, that
1748 can be rendered via C<render_assortment_items_to_html> or
1749 C<render_assembly_items_to_html>.
1751 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1752 Optional param: position (used for numbering and listrow class)
1754 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1756 Takes an array_ref of assortment_items, and generates tables rows ready for
1757 adding to the assortment table. Is used when a part is loaded, or whenever new
1758 assortment items are added.
1760 =item C<parse_form_makemodels>
1762 Makemodels can't just be overwritten, because of the field "lastupdate", that
1763 remembers when the lastcost for that vendor changed the last time.
1765 So the original values are cloned and remembered, so we can compare if lastcost
1766 was changed in $::form, and keep or update lastupdate.
1768 lastcost isn't updated until the first time it was saved with a value, until
1771 Also a boolean "makemodel" needs to be written in parts, depending on whether
1772 makemodel entries exist or not.
1774 We still need init_makemodels for when we open the part for editing.
1784 It should be possible to jump to the edit page in a specific tab
1788 Support callbacks, e.g. creating a new part from within an order, and jumping
1789 back to the order again afterwards.
1793 Support units when adding assembly items or assortment items. Currently the
1794 default unit of the item is always used.
1798 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1799 consists of other assemblies.
1805 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>