1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
11 use List::Util qw(sum);
12 use List::UtilsBy qw(extract_by);
13 use POSIX qw(strftime);
17 use SL::Controller::Helper::GetModels;
19 use SL::DB::BusinessModel;
20 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
23 use SL::DB::PartsGroup;
24 use SL::DB::PriceRuleItem;
25 use SL::DB::PurchaseBasketItem;
27 use SL::Helper::Flash;
28 use SL::Helper::PrintOptions;
29 use SL::Helper::UserPreferences::PartPickerSearch;
31 use SL::Locale::String qw(t8);
32 use SL::MoreCommon qw(save_form);
33 use SL::Presenter::EscapedText qw(escape is_escaped);
34 use SL::Presenter::Part;
35 use SL::Presenter::Tag qw(select_tag);
37 use Rose::Object::MakeMethods::Generic (
38 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
39 makemodels businessmodels shops_not_assigned
42 assortment assortment_items assembly assembly_items
43 all_pricegroups all_translations all_partsgroups all_units
44 all_buchungsgruppen all_payment_terms all_warehouses
45 parts_classification_filter
46 all_languages all_units all_price_factors
48 'scalar' => [ qw(warehouse bin stock_amounts journal) ],
52 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit', 1) || $::auth->assert('part_service_assembly_details') },
53 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
55 __PACKAGE__->run_before(sub { $::auth->assert('developer') },
56 only => [ qw(test_page) ]);
58 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
60 # actions for editing parts
63 my ($self, %params) = @_;
65 $self->part( SL::DB::Part->new_part );
69 sub action_add_service {
70 my ($self, %params) = @_;
72 $self->part( SL::DB::Part->new_service );
76 sub action_add_assembly {
77 my ($self, %params) = @_;
79 $self->part( SL::DB::Part->new_assembly );
83 sub action_add_assortment {
84 my ($self, %params) = @_;
86 $self->part( SL::DB::Part->new_assortment );
90 sub action_add_from_record {
93 check_has_valid_part_type($::form->{part}{part_type});
95 die 'parts_classification_type must be "sales" or "purchases"'
96 unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;
105 check_has_valid_part_type($::form->{part_type});
107 $self->action_add_part if $::form->{part_type} eq 'part';
108 $self->action_add_service if $::form->{part_type} eq 'service';
109 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
110 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
114 my ($self, %params) = @_;
116 # checks that depend only on submitted $::form
117 $self->check_form or return $self->js->render;
119 my $is_new = !$self->part->id; # $ part gets loaded here
121 # check that the part hasn't been modified
123 $self->check_part_not_modified or
124 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;
128 && $::form->{part}{partnumber}
129 && SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
131 return $self->js->error(t8('The partnumber is already being used'))->render;
136 my @errors = $self->part->validate;
137 return $self->js->error(@errors)->render if @errors;
140 # Ensure CVars that should be enabled by default actually are when
141 # creating new parts.
142 my @default_valid_configs =
143 grep { ! $_->{flag_defaults_to_invalid} }
144 grep { $_->{module} eq 'IC' }
145 @{ CVar->get_configs() };
147 $::form->{"cvar_" . $_->{name} . "_valid"} = 1 for @default_valid_configs;
149 $self->{lastcost_modified} = $self->check_lastcost_modified;
152 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
153 $self->part->db->with_transaction(sub {
155 $self->part->save(cascade => 1);
156 $self->part->set_lastcost_assemblies_and_assortiments if $self->{lastcost_modified};
158 SL::DB::History->new(
159 trans_id => $self->part->id,
160 snumbers => 'partnumber_' . $self->part->partnumber,
161 employee_id => SL::DB::Manager::Employee->current->id,
166 CVar->save_custom_variables(
167 dbh => $self->part->db->dbh,
169 trans_id => $self->part->id,
170 variables => $::form, # $::form->{cvar} would be nicer
175 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
177 flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));
179 if ( $::form->{callback} ) {
180 $self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);
183 # default behaviour after save: reload item, this also resets last_modification!
184 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
188 sub action_save_and_purchase_order {
192 if (1 == scalar @{$self->part->makemodels}) {
193 my $prepared_form = Form->new('');
194 $prepared_form->{vendor_id} = $self->part->makemodels->[0]->make;
195 $session_value = $::auth->save_form_in_session(form => $prepared_form);
198 $::form->{callback} = $self->url_for(
199 controller => 'Order',
200 action => 'return_from_create_part',
201 type => 'purchase_order',
202 previousform => $session_value,
205 $self->_run_action('save');
211 if ( $::form->{callback} ) {
212 $self->redirect_to($::form->unescape($::form->{callback}));
219 my $db = $self->part->db; # $self->part has a get_set_init on $::form
221 my $partnumber = $self->part->partnumber; # remember for history log
226 # delete part, together with relationships that don't already
227 # have an ON DELETE CASCADE, e.g. makemodel and translation.
228 $self->part->delete(cascade => 1);
230 SL::DB::History->new(
231 trans_id => $self->part->id,
232 snumbers => 'partnumber_' . $partnumber,
233 employee_id => SL::DB::Manager::Employee->current->id,
235 addition => 'DELETED',
238 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
240 flash_later('info', t8('The item has been deleted.'));
241 if ( $::form->{callback} ) {
242 $self->redirect_to($::form->unescape($::form->{callback}));
244 $self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
248 sub action_use_as_new {
249 my ($self, %params) = @_;
251 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
252 $::form->{oldpartnumber} = $oldpart->partnumber;
254 $self->part($oldpart->clone_and_reset_deep);
255 $self->parse_form(use_as_new => 1);
256 $self->part->partnumber(undef);
258 if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) {
259 # No right to edit prices -> remove prices for new part.
260 $self->part->$_(undef) for qw(sellprice lastcost listprice);
263 $self->render_form(use_as_new => 1);
267 my ($self, %params) = @_;
272 sub action_add_to_basket {
275 if ( !$self->_is_in_purchase_basket && scalar @{$self->part->makemodels}) {
277 my $part = $self->part;
279 my $needed_qty = $part->order_qty < ($part->rop - $part->onhandqty) ?
280 $part->rop - $part->onhandqty
283 my $basket_part = SL::DB::PurchaseBasketItem->new(
284 part_id => $part->id,
286 orderer => SL::DB::Manager::Employee->current,
289 $self->js->flash('info', t8('Part added to purchasebasket'))->render;
291 $self->js->flash('error', t8('Part already in purchasebasket or has no vendor'))->render;
297 my ($self, %params) = @_;
299 $self->_set_javascript;
300 $self->_setup_form_action_bar;
302 my (%assortment_vars, %assembly_vars);
303 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
304 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
306 $params{CUSTOM_VARIABLES} = $params{use_as_new} && $::form->{old_id}
307 ? CVar->get_custom_variables(module => 'IC', trans_id => $::form->{old_id})
308 : CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
311 if (scalar @{ $params{CUSTOM_VARIABLES} }) {
312 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
313 $params{CUSTOM_VARIABLES_FIRST_TAB} = [];
314 @{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
317 my %title_hash = ( part => t8('Edit Part'),
318 assembly => t8('Edit Assembly'),
319 service => t8('Edit Service'),
320 assortment => t8('Edit Assortment'),
323 $self->part->prices([]) unless $self->part->prices;
324 $self->part->translations([]) unless $self->part->translations;
328 title => $title_hash{$self->part->part_type},
331 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
332 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
333 oldpartnumber => $::form->{oldpartnumber},
334 old_id => $::form->{old_id},
342 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
343 $_[0]->render('part/history', { layout => 0 },
344 history_entries => $history_entries);
347 sub action_inventory {
350 $::auth->assert('warehouse_contents');
352 $self->stock_amounts($self->part->get_simple_stock_sql);
353 $self->journal($self->part->get_mini_journal);
355 $_[0]->render('part/_inventory_data', { layout => 0 });
358 sub action_update_item_totals {
361 my $part_type = $::form->{part_type};
362 die unless $part_type =~ /^(assortment|assembly)$/;
364 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
365 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
366 my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');
368 my $sum_diff = $sellprice_sum-$lastcost_sum;
371 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
372 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
373 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
374 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
375 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
376 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
377 ->no_flash_clear->render();
380 sub action_add_multi_assortment_items {
383 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
384 my $html = $self->render_assortment_items_to_html($item_objects);
386 $self->js->run('kivi.Part.close_picker_dialogs')
387 ->append('#assortment_rows', $html)
388 ->run('kivi.Part.renumber_positions')
389 ->run('kivi.Part.assortment_recalc')
393 sub action_add_multi_assembly_items {
396 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
398 foreach my $item (@{$item_objects}) {
399 my $errstr = validate_assembly($item->part,$self->part);
400 $self->js->flash('error',$errstr) if $errstr;
401 push (@checked_objects,$item) unless $errstr;
404 my $html = $self->render_assembly_items_to_html(\@checked_objects);
406 $self->js->run('kivi.Part.close_picker_dialogs')
407 ->append('#assembly_rows', $html)
408 ->run('kivi.Part.renumber_positions')
409 ->run('kivi.Part.assembly_recalc')
413 sub action_add_assortment_item {
414 my ($self, %params) = @_;
416 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
418 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
420 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
421 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
422 return $self->js->flash('error', t8("This part has already been added."))->render;
425 my $number_of_items = scalar @{$self->assortment_items};
426 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
427 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
429 push(@{$self->assortment_items}, @{$item_objects});
430 my $part = SL::DB::Part->new(part_type => 'assortment');
431 $part->assortment_items(@{$self->assortment_items});
432 my $items_sellprice_sum = $part->items_sellprice_sum;
433 my $items_lastcost_sum = $part->items_lastcost_sum;
434 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
437 ->append('#assortment_rows' , $html) # append in tbody
438 ->val('.add_assortment_item_input' , '')
439 ->run('kivi.Part.focus_last_assortment_input')
440 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
441 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
442 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
443 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
444 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
448 sub action_add_assembly_item {
451 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
453 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
455 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
457 my $duplicate_warning = 0; # duplicates are allowed, just warn
458 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
459 $duplicate_warning++;
462 my $number_of_items = scalar @{$self->assembly_items};
463 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
465 foreach my $item (@{$item_objects}) {
466 my $errstr = validate_assembly($item->part,$self->part);
467 return $self->js->flash('error',$errstr)->render if $errstr;
472 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
474 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
476 push(@{$self->assembly_items}, @{$item_objects});
477 my $part = SL::DB::Part->new(part_type => 'assembly');
478 $part->assemblies(@{$self->assembly_items});
479 my $items_sellprice_sum = $part->items_sellprice_sum;
480 my $items_lastcost_sum = $part->items_lastcost_sum;
481 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
482 my $items_weight_sum = $part->items_weight_sum;
485 ->append('#assembly_rows', $html) # append in tbody
486 ->val('.add_assembly_item_input' , '')
487 ->run('kivi.Part.focus_last_assembly_input')
488 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
489 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
490 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
491 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
492 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
493 ->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
497 sub action_show_multi_items_dialog {
500 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
501 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
502 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
504 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
505 all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
506 search_term => $search_term
510 sub action_multi_items_update_result {
511 my $max_count = $::form->{limit};
513 my $count = $_[0]->multi_items_models->count;
516 my $text = escape($::locale->text('No results.'));
517 $_[0]->render($text, { layout => 0 });
518 } elsif ($max_count && $count > $max_count) {
519 my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
520 $_[0]->render($text, { layout => 0 });
522 my $multi_items = $_[0]->multi_items_models->get;
523 $_[0]->render('part/_multi_items_result', { layout => 0 },
524 multi_items => $multi_items);
528 sub action_add_makemodel_row {
531 my $vendor_id = $::form->{add_makemodel};
533 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
534 return $self->js->error(t8("No vendor selected or found!"))->render;
536 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
537 $self->js->flash('info', t8("This vendor has already been added."));
540 my $position = scalar @{$self->makemodels} + 1;
542 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
545 part_description => '',
546 part_longdescription => '',
548 sortorder => $position,
549 ) or die "Can't create MakeModel object";
551 my $row_as_html = $self->p->render('part/_makemodel_row',
553 listrow => $position % 2 ? 0 : 1,
556 # after selection focus on the model field in the row that was just added
558 ->append('#makemodel_rows', $row_as_html) # append in tbody
559 ->val('.add_makemodel_input', '')
560 ->run('kivi.Part.focus_last_makemodel_input')
564 sub action_add_businessmodel_row {
567 my $business_id = $::form->{add_businessmodel};
569 my $business = SL::DB::Manager::Business->find_by(id => $business_id) or
570 return $self->js->error(t8("No business selected or found!"))->render;
572 if ( grep { $business_id == $_->business_id } @{ $self->businessmodels } ) {
574 ->scroll_into_view('#content')
575 ->flash('error', (t8("This business has already been added.")))
579 my $position = scalar @{ $self->businessmodels } + 1;
581 my $bm = SL::DB::BusinessModel->new(#parts_id => $::form->{part}->{id},
582 business => $business,
584 part_description => '',
585 part_longdescription => '',
586 position => $position,
587 ) or die "Can't create BusinessModel object";
589 my $row_as_html = $self->p->render('part/_businessmodel_row',
590 businessmodel => $bm);
592 # after selection focus on the model field in the row that was just added
594 ->append('#businessmodel_rows', $row_as_html) # append in tbody
595 ->val('#add_businessmodel', '')
596 ->run('kivi.Part.focus_last_businessmodel_input')
600 sub action_add_customerprice_row {
603 my $customer_id = $::form->{add_customerprice};
605 my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
606 or return $self->js->error(t8("No customer selected or found!"))->render;
608 if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
609 $self->js->flash('info', t8("This customer has already been added."));
612 my $position = scalar @{ $self->customerprices } + 1;
614 my $cu = SL::DB::PartCustomerPrice->new(
615 customer_id => $customer->id,
616 customer_partnumber => '',
617 part_description => '',
618 part_longdescription => '',
620 sortorder => $position,
621 ) or die "Can't create Customerprice object";
623 my $row_as_html = $self->p->render(
624 'part/_customerprice_row',
625 customerprice => $cu,
626 listrow => $position % 2 ? 0
630 $self->js->append('#customerprice_rows', $row_as_html) # append in tbody
631 ->val('.add_customerprice_input', '')
632 ->run('kivi.Part.focus_last_customerprice_input')->render;
635 sub action_reorder_items {
638 my $part_type = $::form->{part_type};
641 partnumber => sub { $_[0]->part->partnumber },
642 description => sub { $_[0]->part->description },
643 qty => sub { $_[0]->qty },
644 sellprice => sub { $_[0]->part->sellprice },
645 lastcost => sub { $_[0]->part->lastcost },
646 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
649 my $method = $sort_keys{$::form->{order_by}};
652 if ($part_type eq 'assortment') {
653 @items = @{ $self->assortment_items };
655 @items = @{ $self->assembly_items };
658 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
659 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
660 if ($::form->{sort_dir}) {
661 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
663 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
666 if ($::form->{sort_dir}) {
667 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
669 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
673 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
676 sub action_warehouse_changed {
679 if ($::form->{warehouse_id} ) {
680 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
681 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
683 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
684 $self->bin($self->warehouse->bins_sorted_naturally->[0]);
686 ->html('#bin', $self->build_bin_select)
687 ->focus('#part_bin_id');
688 return $self->js->render;
692 # no warehouse was selected, empty the bin field and reset the id
694 ->val('#part_bin_id', undef)
697 return $self->js->render;
700 sub action_ajax_autocomplete {
701 my ($self, %params) = @_;
703 # if someone types something, and hits enter, assume he entered the full name.
704 # if something matches, treat that as sole match
705 # since we need a second get models instance with different filters for that,
706 # we only modify the original filter temporarily in place
707 if ($::form->{prefer_exact}) {
708 local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
709 local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
710 local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};
712 my $exact_models = SL::Controller::Helper::GetModels->new(
715 paginated => { per_page => 2 },
716 with_objects => [ qw(unit_obj classification) ],
719 if (1 == scalar @{ $exact_matches = $exact_models->get }) {
720 $self->parts($exact_matches);
726 value => $_->displayable_name,
727 label => $_->displayable_name,
729 partnumber => $_->partnumber,
730 description => $_->description,
732 part_type => $_->part_type,
734 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
736 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
738 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
741 sub action_test_page {
742 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
745 sub action_part_picker_search {
748 my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
749 $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
750 $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};
752 my $all_as_list = SL::Helper::UserPreferences::PartPickerSearch->new()->get_all_as_list_default;
754 $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term, all_as_list => $all_as_list);
757 sub action_part_picker_result {
758 $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
764 if ($::request->type eq 'json') {
769 $part_hash = $self->part->as_tree;
770 $part_hash->{cvars} = $self->part->cvar_as_hashref;
773 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
777 sub action_showdetails {
778 my ($self, %params) = @_;
781 my $bins = SL::DB::Manager::Bin->get_all(with_objects => ['warehouse' ]);
782 my %bins_by_id = map { $_->id => $_ } @$bins;
783 my $inventories = SL::DB::Manager::Inventory->get_all(where => [ parts_id => $self->part->id],
784 with_objects => ['parts', 'trans_type' ], sort_by => 'bin_id ASC');
785 foreach my $bin (@{ $bins }) {
789 foreach my $inv (@{ $inventories }) {
790 my $bin = $bins_by_id{ $inv->bin_id };
791 $bin->{qty} += $inv->qty;
792 $bin->{unit} = $inv->parts->unit;
795 for my $bin (@{ $bins }) {
797 'warehouse' => $bin->warehouse->description,
798 'description' => $bin->description,
799 'qty' => $bin->{qty},
800 'unit' => $bin->{unit},
801 } if $bin->{qty} != 0;
806 my $todate = DateTime->now_local;
807 my $fromdate = DateTime->now_local->add_duration(DateTime::Duration->new(years => -1));
809 foreach my $inv (@{ $inventories }) {
810 $average += abs($inv->qty) if $inv->shippingdate && $inv->trans_type->direction eq 'out' &&
811 DateTime->compare($inv->shippingdate,$fromdate) != -1 &&
812 DateTime->compare($inv->shippingdate,$todate) == -1;
814 my $openitems = SL::DB::Manager::OrderItem->get_all(where => [ parts_id => $self->part->id, 'order.closed' => 0 ],
815 with_objects => ['order'],);
816 my ($not_delivered, $ordered) = 0;
817 for my $openitem (@{ $openitems }) {
818 if($openitem -> order -> type eq 'sales_order') {
819 $not_delivered += $openitem->qty - $openitem->shipped_qty;
820 } elsif ( $openitem->order->type eq 'purchase_order' ) {
821 $ordered += $openitem->qty - $openitem->delivered_qty;
825 my $stock_amounts = $self->part->get_simple_stock_sql;
827 my $output = SL::Presenter->get->render('part/showdetails',
829 stock_amounts => $stock_amounts,
830 average => $average/12,
831 fromdate => $fromdate,
834 not_delivered => $not_delivered,
836 print_options => SL::Helper::PrintOptions->get_print_options(
839 printers => SL::DB::Manager::Printer->get_all_sorted,
842 dialog_name_prefix => 'print_options.',
846 no_opendocument => 1,
847 hide_language_id_print => 1,
852 $self->render(\$output, { layout => 0, process => 0 });
855 sub action_print_label {
858 return $self->render('generic/error', { layout => 1 }, label_error => t8('Not implemented yet!'));
861 sub action_export_assembly_assortment_components {
864 my $bom_or_charge = $self->part->is_assembly ? 'bom' : 'charge';
867 $::locale->text('Partnumber'),
868 $::locale->text('Description'),
869 $::locale->text('Type'),
870 $::locale->text('Classification'),
871 $::locale->text('Qty'),
872 $::locale->text('Unit'),
873 $self->part->is_assembly ? $::locale->text('BOM') : $::locale->text('Charge'),
874 $::locale->text('Line Total'),
875 $::locale->text('Sellprice'),
876 $::locale->text('Lastcost'),
877 $::locale->text('Partsgroup'),
880 foreach my $item (@{ $self->part->items }) {
881 my $part = $item->part;
886 SL::Presenter::Part::type_abbreviation($part->part_type),
887 SL::Presenter::Part::classification_abbreviation($part->classification_id),
888 $item->qty_as_number,
890 $item->$bom_or_charge ? $::locale->text('yes') : $::locale->text('no'),
891 $::form->format_amount(\%::myconfig, $item->linetotal_sellprice, 3, 0),
892 $part->sellprice_as_number,
893 $part->lastcost_as_number,
894 $part->partsgroup ? $part->partsgroup->partsgroup : '',
900 my $csv = Text::CSV_XS->new({
906 my ($file_handle, $file_name) = File::Temp::tempfile;
908 binmode $file_handle, ":encoding(utf8)";
910 $csv->print($file_handle, $_) for @rows;
914 my $type_prefix = $self->part->is_assembly ? 'assembly' : 'assortment';
915 my $part_number = $self->part->partnumber;
916 $part_number =~ s{[^[:word:]]+}{_}g;
917 my $timestamp = strftime('_%Y-%m-%d_%H-%M-%S', localtime());
918 my $attachment_name = sprintf('%s_components_%s_%s.csv', $type_prefix, $part_number, $timestamp);
922 content_type => 'text/csv',
923 name => $attachment_name,
929 sub validate_add_items {
930 scalar @{$::form->{add_items}};
933 sub prepare_assortment_render_vars {
936 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
937 items_lastcost_sum => $self->part->items_lastcost_sum,
938 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
940 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
945 sub prepare_assembly_render_vars {
948 croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;
950 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
951 items_lastcost_sum => $self->part->items_lastcost_sum,
952 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
954 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
962 check_has_valid_part_type($self->part->part_type);
964 $self->_set_javascript;
965 $self->_setup_form_action_bar;
967 my %title_hash = ( part => t8('Add Part'),
968 assembly => t8('Add Assembly'),
969 service => t8('Add Service'),
970 assortment => t8('Add Assortment'),
975 title => $title_hash{$self->part->part_type},
980 sub _set_javascript {
982 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule kivi.ShopPart kivi.Validator);
983 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
986 sub recalc_item_totals {
987 my ($self, %params) = @_;
989 if ( $params{part_type} eq 'assortment' ) {
990 return 0 unless scalar @{$self->assortment_items};
991 } elsif ( $params{part_type} eq 'assembly' ) {
992 return 0 unless scalar @{$self->assembly_items};
994 carp "can only calculate sum for assortments and assemblies";
997 my $part = SL::DB::Part->new(part_type => $params{part_type});
998 if ( $part->is_assortment ) {
999 $part->assortment_items( @{$self->assortment_items} );
1000 if ( $params{price_type} eq 'lastcost' ) {
1001 return $part->items_lastcost_sum;
1003 if ( $params{pricegroup_id} ) {
1004 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
1006 return $part->items_sellprice_sum;
1009 } elsif ( $part->is_assembly ) {
1010 $part->assemblies( @{$self->assembly_items} );
1011 if ( $params{price_type} eq 'weight' ) {
1012 return $part->items_weight_sum;
1013 } elsif ( $params{price_type} eq 'lastcost' ) {
1014 return $part->items_lastcost_sum;
1016 return $part->items_sellprice_sum;
1021 sub check_part_not_modified {
1024 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
1028 sub check_lastcost_modified {
1031 return (abs($self->part->lastcost - $self->part->last_price_update->lastcost) >= 0.009)
1032 || (abs(($self->part->price_factor ? $self->part->price_factor->factor : 1) - $self->part->last_price_update->price_factor) >= 0.009);
1036 my ($self, %params) = @_;
1038 my $is_new = !$self->part->id;
1040 my $params = delete($::form->{part}) || { };
1042 if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) {
1043 # No right to set or change prices, so delete prices from params.
1044 delete $params->{$_} for qw(sellprice_as_number lastcost_as_number listprice_as_number);
1047 delete $params->{id};
1048 $self->part->assign_attributes(%{ $params});
1049 $self->part->bin_id(undef) unless $self->part->warehouse_id;
1051 $self->normalize_text_blocks;
1053 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
1054 # will be the case for used assortments when saving, or when a used assortment
1056 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
1057 $self->part->assortment_items([]);
1058 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
1061 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
1062 $self->part->assemblies([]); # completely rewrite assortments each time
1063 $self->part->add_assemblies( @{ $self->assembly_items } );
1066 # Update lastcost for assemblies
1067 if ($self->part->is_assembly) {
1068 my $lastcost_sum = $self->recalc_item_totals(part_type => $self->part->part_type, price_type => 'lastcost');
1069 $self->part->lastcost($lastcost_sum);
1072 $self->part->translations([]) unless $params{use_as_new};
1073 $self->parse_form_translations;
1075 if ($::auth->assert('part_service_assembly_edit_prices', 'may_fail')) {
1076 $self->part->prices([]);
1077 $self->parse_form_prices;
1080 $self->parse_form_customerprices;
1081 $self->parse_form_makemodels;
1082 $self->parse_form_businessmodels;
1085 sub parse_form_prices {
1087 # only save prices > 0
1088 my $prices = delete($::form->{prices}) || [];
1089 foreach my $price ( @{$prices} ) {
1090 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
1091 next unless $sellprice > 0; # skip negative prices as well
1092 my $p = SL::DB::Price->new(parts_id => $self->part->id,
1093 pricegroup_id => $price->{pricegroup_id},
1094 price => $sellprice,
1096 $self->part->add_prices($p);
1100 sub parse_form_translations {
1102 # don't add empty translations
1103 my $translations = delete($::form->{translations}) || [];
1104 foreach my $translation ( @{$translations} ) {
1105 next unless $translation->{translation};
1106 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
1107 $self->part->add_translations( $translation );
1111 sub parse_form_makemodels {
1115 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
1116 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
1119 $self->part->makemodels([]);
1122 my $makemodels = delete($::form->{makemodels}) || [];
1123 foreach my $makemodel ( @{$makemodels} ) {
1124 next unless $makemodel->{make};
1126 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
1128 my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
1129 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
1131 make => $makemodel->{make},
1132 model => $makemodel->{model} || '',
1133 part_description => $makemodel->{part_description},
1134 part_longdescription => $makemodel->{part_longdescription},
1135 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
1136 sortorder => $position,
1139 if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) {
1140 # No right to edit prices -> restore old lastcost.
1141 $mm->lastcost($makemodels_map->{$id} ? $makemodels_map->{$id}->lastcost : undef);
1144 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
1145 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
1146 # don't change lastupdate
1147 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
1148 # new makemodel, no lastcost entered, leave lastupdate empty
1149 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
1150 # lastcost hasn't changed, use original lastupdate
1151 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
1153 $mm->lastupdate(DateTime->now);
1155 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
1156 $self->part->add_makemodels($mm);
1160 sub parse_form_businessmodels {
1163 my $make_key = sub { return $_[0]->parts_id . '+' . $_[0]->business_id; };
1165 my $businessmodels_map;
1166 if ( $self->part->businessmodels ) { # check for new parts or parts without businessmodels
1167 $businessmodels_map = { map { $make_key->($_) => Rose::DB::Object::Helpers::clone($_) } @{$self->part->businessmodels} };
1170 $self->part->businessmodels([]);
1173 my $businessmodels = delete($::form->{businessmodels}) || [];
1174 foreach my $businessmodel ( @{$businessmodels} ) {
1175 next unless $businessmodel->{business_id};
1178 my $bm = SL::DB::BusinessModel->new( #parts_id => $self->part->id, # will be assigned by row add_businessmodels
1179 business_id => $businessmodel->{business_id},
1180 model => $businessmodel->{model} || '',
1181 part_description => $businessmodel->{part_description} || '',
1182 part_longdescription => $businessmodel->{part_longdescription} || '',
1183 position => $position,
1186 $self->part->add_businessmodels($bm);
1190 sub parse_form_customerprices {
1193 my $customerprices_map;
1194 if ( $self->part->customerprices ) { # check for new parts or parts without customerprices
1195 $customerprices_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->customerprices} };
1198 $self->part->customerprices([]);
1201 my $customerprices = delete($::form->{customerprices}) || [];
1202 foreach my $customerprice ( @{$customerprices} ) {
1203 next unless $customerprice->{customer_id};
1205 my $customer = SL::DB::Manager::Customer->find_by(id => $customerprice->{customer_id}) || die "Can't find customer from id";
1207 my $id = $customerprices_map->{$customerprice->{id}} ? $customerprices_map->{$customerprice->{id}}->id : undef;
1208 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1210 customer_id => $customerprice->{customer_id},
1211 customer_partnumber => $customerprice->{customer_partnumber} || '',
1212 part_description => $customerprice->{part_description},
1213 part_longdescription => $customerprice->{part_longdescription},
1214 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}),
1215 sortorder => $position,
1218 if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) {
1219 # No right to edit prices -> restore old price.
1220 $cu->price($customerprices_map->{$id} ? $customerprices_map->{$id}->price : undef);
1223 if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) {
1224 # lastupdate isn't set, original price is 0 and new lastcost is 0
1225 # don't change lastupdate
1226 } elsif ( !$customerprices_map->{$cu->id} && $cu->price == 0 ) {
1227 # new customerprice, no lastcost entered, leave lastupdate empty
1228 } elsif ($customerprices_map->{$cu->id} && $customerprices_map->{$cu->id}->price == $cu->price) {
1229 # price hasn't changed, use original lastupdate
1230 $cu->lastupdate($customerprices_map->{$cu->id}->lastupdate);
1232 $cu->lastupdate(DateTime->now);
1234 $self->part->add_customerprices($cu);
1238 sub build_bin_select {
1239 select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted_naturally } ],
1240 title_key => 'description',
1241 default => $_[0]->bin->id,
1246 # get_set_inits for partpicker
1249 if ($::form->{no_paginate}) {
1250 $_[0]->models->disable_plugin('paginated');
1256 # get_set_inits for part controller
1260 # used by edit, save, delete and add
1262 if ( $::form->{part}{id} ) {
1263 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels businessmodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]);
1264 } elsif ( $::form->{id} ) {
1265 return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab
1267 die "part_type missing" unless $::form->{part}{part_type};
1268 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
1274 return $self->part->orphaned;
1280 SL::Controller::Helper::GetModels->new(
1281 controller => $self,
1287 partnumber => t8('Partnumber'),
1288 description => t8('Description'),
1290 with_objects => [ qw(unit_obj classification) ],
1299 sub init_assortment_items {
1300 # this init is used while saving and whenever assortments change dynamically
1304 my $assortment_items = delete($::form->{assortment_items}) || [];
1305 foreach my $assortment_item ( @{$assortment_items} ) {
1306 next unless $assortment_item->{parts_id};
1308 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
1309 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
1310 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
1311 charge => $assortment_item->{charge},
1312 unit => $assortment_item->{unit} || $part->unit,
1313 position => $position,
1321 sub init_makemodels {
1325 my @makemodel_array = ();
1326 my $makemodels = delete($::form->{makemodels}) || [];
1328 foreach my $makemodel ( @{$makemodels} ) {
1329 next unless $makemodel->{make};
1331 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
1332 id => $makemodel->{id},
1333 make => $makemodel->{make},
1334 model => $makemodel->{model} || '',
1335 part_description => $makemodel->{part_description} || '',
1336 part_longdescription => $makemodel->{part_longdescription} || '',
1337 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
1338 sortorder => $position,
1339 ) or die "Can't create mm";
1340 # $mm->id($makemodel->{id}) if $makemodel->{id};
1341 push(@makemodel_array, $mm);
1343 return \@makemodel_array;
1346 sub init_businessmodels {
1349 my @businessmodel_array = ();
1350 my $businessmodels = delete($::form->{businessmodels}) || [];
1352 foreach my $businessmodel ( @{$businessmodels} ) {
1353 next unless $businessmodel->{business_id};
1355 my $bm = SL::DB::BusinessModel->new(#parts_id => $self->part->id, # will be assigned by row add_businessmodels
1356 business_id => $businessmodel->{business_id},
1357 model => $businessmodel->{model} || '',
1358 part_description => $businessmodel->{part_description} || '',
1359 part_longdescription => $businessmodel->{part_longdescription} || '',
1360 ) or die "Can't create bm";
1362 push(@businessmodel_array, $bm);
1365 return \@businessmodel_array;
1368 sub init_customerprices {
1372 my @customerprice_array = ();
1373 my $customerprices = delete($::form->{customerprices}) || [];
1375 foreach my $customerprice ( @{$customerprices} ) {
1376 next unless $customerprice->{customer_id};
1378 my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices
1379 id => $customerprice->{id},
1380 customer_partnumber => $customerprice->{customer_partnumber},
1381 customer_id => $customerprice->{customer_id} || '',
1382 part_description => $customerprice->{part_description},
1383 part_longdescription => $customerprice->{part_longdescription},
1384 price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0),
1385 sortorder => $position,
1386 ) or die "Can't create cu";
1387 # $cu->id($customerprice->{id}) if $customerprice->{id};
1388 push(@customerprice_array, $cu);
1390 return \@customerprice_array;
1393 sub init_assembly_items {
1397 my $assembly_items = delete($::form->{assembly_items}) || [];
1398 foreach my $assembly_item ( @{$assembly_items} ) {
1399 next unless $assembly_item->{parts_id};
1401 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
1402 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
1403 bom => $assembly_item->{bom},
1404 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
1405 position => $position,
1412 sub init_all_warehouses {
1414 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
1417 sub init_all_languages {
1418 SL::DB::Manager::Language->get_all_sorted;
1421 sub init_all_partsgroups {
1423 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
1426 sub init_all_buchungsgruppen {
1428 if (!$self->part->orphaned) {
1429 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]);
1432 return SL::DB::Manager::Buchungsgruppe->get_all_sorted(
1435 id => $self->part->buchungsgruppen_id,
1442 sub init_shops_not_assigned {
1445 my @used_shop_ids = map { $_->shop->id } @{ $self->part->shop_parts };
1446 if ( @used_shop_ids ) {
1447 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0, '!id' => \@used_shop_ids ], sort_by => 'sortkey' );
1450 return SL::DB::Manager::Shop->get_all( query => [ obsolete => 0 ], sort_by => 'sortkey' );
1454 sub init_all_units {
1456 if ( $self->part->orphaned ) {
1457 return SL::DB::Manager::Unit->get_all_sorted;
1459 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
1463 sub init_all_payment_terms {
1465 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
1468 sub init_all_price_factors {
1469 SL::DB::Manager::PriceFactor->get_all_sorted;
1472 sub init_all_pricegroups {
1473 SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]);
1476 sub init_all_businesses {
1477 SL::DB::Manager::Business->get_all_sorted;
1480 # model used to filter/display the parts in the multi-items dialog
1481 sub init_multi_items_models {
1482 SL::Controller::Helper::GetModels->new(
1483 controller => $_[0],
1485 with_objects => [ qw(unit_obj partsgroup classification) ],
1486 disable_plugin => 'paginated',
1487 source => $::form->{multi_items},
1493 partnumber => t8('Partnumber'),
1494 description => t8('Description')}
1498 sub init_parts_classification_filter {
1499 return [] unless $::form->{parts_classification_type};
1501 return [ used_for_sale => 't' ] if $::form->{parts_classification_type} eq 'sales';
1502 return [ used_for_purchase => 't' ] if $::form->{parts_classification_type} eq 'purchases';
1504 die "no query rules for parts_classification_type " . $::form->{parts_classification_type};
1507 # simple checks to run on $::form before saving
1509 sub form_check_part_description_exists {
1512 return 1 if $::form->{part}{description};
1514 $self->js->flash('error', t8('Part Description missing!'))
1515 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
1516 ->focus('#part_description');
1520 sub form_check_assortment_items_exist {
1523 return 1 unless $::form->{part}{part_type} eq 'assortment';
1524 # skip item check for existing assortments that have been used
1525 return 1 if ($self->part->id and !$self->part->orphaned);
1527 # new or orphaned parts must have items in $::form->{assortment_items}
1528 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
1529 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1530 ->focus('#add_assortment_item_name')
1531 ->flash('error', t8('The assortment doesn\'t have any items.'));
1537 sub form_check_assortment_items_unique {
1540 return 1 unless $::form->{part}{part_type} eq 'assortment';
1542 my %duplicate_elements;
1544 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
1545 $duplicate_elements{$_}++ if $count{$_}++;
1548 if ( keys %duplicate_elements ) {
1549 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
1550 ->flash('error', t8('There are duplicate assortment items'));
1556 sub form_check_assembly_items_exist {
1559 return 1 unless $::form->{part}->{part_type} eq 'assembly';
1561 # skip item check for existing assembly that have been used
1562 return 1 if ($self->part->id and !$self->part->orphaned);
1564 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1565 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1566 ->focus('#add_assembly_item_name')
1567 ->flash('error', t8('The assembly doesn\'t have any items.'));
1573 sub form_check_partnumber_is_unique {
1576 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1577 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1579 $self->js->flash('error', t8('The partnumber already exists!'))
1580 ->focus('#part_description');
1587 sub form_check_buchungsgruppe {
1590 return 1 if $::form->{part}->{obsolete};
1592 my $buchungsgruppe = SL::DB::Buchungsgruppe->new(id => $::form->{part}->{buchungsgruppen_id})->load;
1594 return 1 if !$buchungsgruppe->obsolete;
1596 $self->js->flash('error', t8("The booking group '#1' is obsolete and cannot be used with active articles.", $buchungsgruppe->description))
1597 ->focus('#part_buchungsgruppen_id');
1602 # general checking functions
1605 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1611 $self->form_check_part_description_exists || return 0;
1612 $self->form_check_assortment_items_exist || return 0;
1613 $self->form_check_assortment_items_unique || return 0;
1614 $self->form_check_assembly_items_exist || return 0;
1615 $self->form_check_partnumber_is_unique || return 0;
1616 $self->form_check_buchungsgruppe || return 0;
1621 sub check_has_valid_part_type {
1622 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1626 sub normalize_text_blocks {
1629 # check if feature is enabled (select normalize_part_descriptions from defaults)
1630 return unless ($::instance_conf->get_normalize_part_descriptions);
1633 foreach (qw(description)) {
1634 $self->part->{$_} =~ s/\s+$//s;
1635 $self->part->{$_} =~ s/^\s+//s;
1636 $self->part->{$_} =~ s/ {2,}/ /g;
1638 # html block (caveat: can be circumvented by using bold or italics)
1639 $self->part->{notes} =~ s/^<p>( )+\s+/<p>/s;
1640 $self->part->{notes} =~ s/( )+<\/p>$/<\/p>/s;
1644 sub render_assortment_items_to_html {
1645 my ($self, $assortment_items, $number_of_items) = @_;
1647 my $position = $number_of_items + 1;
1649 foreach my $ai (@$assortment_items) {
1650 $html .= $self->p->render('part/_assortment_row',
1651 PART => $self->part,
1652 orphaned => $self->orphaned,
1654 listrow => $position % 2 ? 1 : 0,
1655 position => $position, # for legacy assemblies
1662 sub render_assembly_items_to_html {
1663 my ($self, $assembly_items, $number_of_items) = @_;
1665 my $position = $number_of_items + 1;
1667 foreach my $ai (@{$assembly_items}) {
1668 $html .= $self->p->render('part/_assembly_row',
1669 PART => $self->part,
1670 orphaned => $self->orphaned,
1672 listrow => $position % 2 ? 1 : 0,
1673 position => $position, # for legacy assemblies
1680 sub parse_add_items_to_objects {
1681 my ($self, %params) = @_;
1682 my $part_type = $params{part_type};
1683 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1684 my $position = $params{position} || 1;
1686 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1689 foreach my $item ( @add_items ) {
1690 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1692 if ( $part_type eq 'assortment' ) {
1693 $ai = SL::DB::AssortmentItem->new(part => $part,
1694 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1695 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1696 position => $position,
1697 ) or die "Can't create AssortmentItem from item";
1698 } elsif ( $part_type eq 'assembly' ) {
1699 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1700 # id => $self->assembly->id, # will be set on save
1701 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1702 bom => 0, # default when adding: no bom
1703 position => $position,
1706 die "part_type must be assortment or assembly";
1708 push(@item_objects, $ai);
1712 return \@item_objects;
1715 sub _is_in_purchase_basket {
1718 return SL::DB::Manager::PurchaseBasketItem->get_all_count( query => [ part_id => $self->part->id ] );
1724 return $self->part->get_ordered_qty( $self->part->id );
1727 sub _setup_form_action_bar {
1730 my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1731 my $used_in_pricerules = !!SL::DB::Manager::PriceRuleItem->get_all_count(where => [type => 'part', value_int => $self->part->id]);
1733 for my $bar ($::request->layout->get('actionbar')) {
1738 call => [ 'kivi.Part.save' ],
1739 disabled => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1740 checks => ['kivi.validate_form'],
1744 call => [ 'kivi.Part.use_as_new' ],
1745 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1746 : !$may_edit ? t8('You do not have the permissions to access this function.')
1749 ], # end of combobox "Save"
1752 action => [ t8('Workflow') ],
1754 t8('Save and Purchase Order'),
1755 submit => [ '#ic', { action => "Part/save_and_purchase_order" } ],
1756 checks => ['kivi.validate_form'],
1757 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1758 : !$may_edit ? t8('You do not have the permissions to access this function.')
1759 : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.')
1760 : $self->part->order_locked ? t8('This part should not be ordered any more.')
1762 only_if => !$::form->{inline_create},
1769 only_if => $self->part->is_assembly || $self->part->is_assortment,
1772 $self->part->is_assembly ? t8('Assembly items') : t8('Assortment items'),
1773 submit => [ '#ic', { action => "Part/export_assembly_assortment_components" } ],
1774 checks => ['kivi.validate_form'],
1775 disabled => !$self->part->id ? t8('The object has not been saved yet.')
1776 : !$may_edit ? t8('You do not have the permissions to access this function.')
1777 : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.')
1779 only_if => $self->part->is_assembly || $self->part->is_assortment,
1785 submit => [ '#ic', { action => "Part/abort" } ],
1786 only_if => !!$::form->{inline_create},
1791 call => [ 'kivi.Part.delete' ],
1792 confirm => t8('Do you really want to delete this object?'),
1793 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1794 : !$may_edit ? t8('You do not have the permissions to access this function.')
1795 : !$self->part->orphaned ? t8('This object has already been used.')
1796 : $used_in_pricerules ? t8('This object is used in price rules.')
1801 t8('Add to basket'),
1802 call => [ 'kivi.Part.add_to_basket' ],
1803 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1804 : $self->_is_in_purchase_basket ? t8('Part already in purchasebasket')
1805 : $self->_is_ordered ? t8('Part already ordered')
1806 : !scalar @{$self->part->makemodels} ? t8('No vendors to add to purchasebasket')
1814 call => [ 'kivi.Part.open_history_popup' ],
1815 disabled => !$self->part->id ? t8('This object has not been saved yet.')
1816 : !$may_edit ? t8('You do not have the permissions to access this function.')
1831 SL::Controller::Part - Part CRUD controller
1835 Controller for adding/editing/saving/deleting parts.
1837 All the relations are loaded at once and saving the part, adding a history
1838 entry and saving CVars happens inside one transaction. When saving the old
1839 relations are deleted and written as new to the database.
1841 Relations for parts:
1849 =item assembly items
1851 =item assortment items
1859 There are 4 different part types:
1865 The "default" part type.
1867 inventory_accno_id is set.
1871 Services can't be stocked.
1873 inventory_accno_id isn't set.
1877 Assemblies consist of other parts, services, assemblies or assortments. They
1878 aren't meant to be bought, only sold. To add assemblies to stock you typically
1879 have to make them, which reduces the stock by its respective components. Once
1880 an assembly item has been created there is currently no way to "disassemble" it
1881 again. An assembly item can appear several times in one assembly. An assmbly is
1882 sold as one item with a defined sellprice and lastcost. If the component prices
1883 change the assortment price remains the same. The assembly items may be printed
1884 in a record if the item's "bom" is set.
1888 Similar to assembly, but each assortment item may only appear once per
1889 assortment. When selling an assortment the assortment items are added to the
1890 record together with the assortment, which is added with sellprice 0.
1892 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1893 determined by the sum of the current assortment item prices when the assortment
1894 is added to a record. This also means that price rules and customer discounts
1895 will be applied to the assortment items.
1897 Once the assortment items have been added they may be modified or deleted, just
1898 as if they had been added manually, the individual assortment items aren't
1899 linked to the assortment or the other assortment items in any way.
1907 =item C<action_add_part>
1909 =item C<action_add_service>
1911 =item C<action_add_assembly>
1913 =item C<action_add_assortment>
1915 =item C<action_add PART_TYPE>
1917 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1918 parameter part_type as an action. Example:
1920 controller.pl?action=Part/add&part_type=service
1922 =item C<action_add_from_record>
1924 When adding new items to records they can be created on the fly if the entered
1925 partnumber or description doesn't exist yet. After being asked what part type
1926 the new item should have the user is redirected to the correct edit page.
1928 Depending on whether the item was added from a sales or a purchase record, only
1929 the relevant part classifications should be selectable for new item, so this
1930 parameter is passed on via a hidden parts_classification_type in the new_item
1933 =item C<action_save>
1935 Saves the current part and then reloads the edit page for the part.
1937 =item C<action_use_as_new>
1939 Takes the information from the current part, plus any modifications made on the
1940 page, and creates a new edit page that is ready to be saved. The partnumber is
1941 set empty, so a new partnumber from the number range will be used if the user
1942 doesn't enter one manually.
1944 Unsaved changes to the original part aren't updated.
1946 The part type cannot be changed in this way.
1948 =item C<action_delete>
1950 Deletes the current part and then redirects to the main page, there is no
1953 The delete button only appears if the part is 'orphaned', according to
1954 SL::DB::Part orphaned.
1956 The part can't be deleted if it appears in invoices, orders, delivery orders,
1957 the inventory, or is part of an assembly or assortment.
1959 If the part is deleted its relations prices, makdemodel, assembly,
1960 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1962 Before this controller items that appeared in inventory didn't count as
1963 orphaned and could be deleted and the inventory entries were also deleted, this
1964 "feature" hasn't been implemented.
1966 =item C<action_edit part.id>
1968 Load and display a part for editing.
1970 controller.pl?action=Part/edit&part.id=12345
1972 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1976 =head1 BUTTON ACTIONS
1982 Opens a popup displaying all the history entries. Once a new history controller
1983 is written the button could link there instead, with the part already selected.
1991 =item C<action_update_item_totals>
1993 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1994 amount of an item changes. The sum of all sellprices and lastcosts is
1995 calculated and the totals updated. Uses C<recalc_item_totals>.
1997 =item C<action_add_assortment_item>
1999 Adds a new assortment item from a part picker seleciton to the assortment item list
2001 If the item already exists in the assortment the item isn't added and a Flash
2004 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
2005 after adding each new item, add the new object to the item objects that were
2006 already parsed, calculate totals via a dummy part then update the row and the
2009 =item C<action_add_assembly_item>
2011 Adds a new assembly item from a part picker seleciton to the assembly item list
2013 If the item already exists in the assembly a flash info is generated, but the
2016 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
2017 after adding each new item, add the new object to the item objects that were
2018 already parsed, calculate totals via a dummy part then update the row and the
2021 =item C<action_add_multi_assortment_items>
2023 Parses the items to be added from the form generated by the multi input and
2024 appends the html of the tr-rows to the assortment item table. Afterwards all
2025 assortment items are renumbered and the sums recalculated via
2026 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
2028 =item C<action_add_multi_assembly_items>
2030 Parses the items to be added from the form generated by the multi input and
2031 appends the html of the tr-rows to the assembly item table. Afterwards all
2032 assembly items are renumbered and the sums recalculated via
2033 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
2035 =item C<action_show_multi_items_dialog>
2037 =item C<action_multi_items_update_result>
2039 =item C<action_add_makemodel_row>
2041 Add a new makemodel row with the vendor that was selected via the vendor
2044 Checks the already existing makemodels and warns if a row with that vendor
2045 already exists. Currently it is possible to have duplicate vendor rows.
2047 =item C<action_reorder_items>
2049 Sorts the item table for assembly or assortment items.
2051 =item C<action_warehouse_changed>
2055 =head1 ACTIONS part picker
2059 =item C<action_ajax_autocomplete>
2061 =item C<action_test_page>
2063 =item C<action_part_picker_search>
2065 =item C<action_part_picker_result>
2067 =item C<action_show>
2077 Calls some simple checks that test the submitted $::form for obvious errors.
2078 Return 1 if all the tests were successfull, 0 as soon as one test fails.
2080 Errors from the failed tests are stored as ClientJS actions in $self->js. In
2081 some cases extra actions are taken, e.g. if the part description is missing the
2082 basic data tab is selected and the description input field is focussed.
2088 =item C<form_check_part_description_exists>
2090 =item C<form_check_assortment_items_exist>
2092 =item C<form_check_assortment_items_unique>
2094 =item C<form_check_assembly_items_exist>
2096 =item C<form_check_partnumber_is_unique>
2100 =head1 HELPER FUNCTIONS
2106 When submitting the form for saving, parses the transmitted form. Expects the
2110 $::form->{makemodels}
2111 $::form->{translations}
2113 $::form->{assemblies}
2114 $::form->{assortments}
2116 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
2118 =item C<recalc_item_totals %params>
2120 Helper function for calculating the total lastcost and sellprice for assemblies
2121 or assortments according to their items, which are parsed from the current
2124 Is called whenever the qty of an item is changed or items are deleted.
2128 * part_type : 'assortment' or 'assembly' (mandatory)
2130 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
2132 Depending on the price_type the lastcost sum or sellprice sum is returned.
2134 Doesn't work for recursive items.
2138 =head1 GET SET INITS
2140 There are get_set_inits for
2148 which parse $::form and automatically create an array of objects.
2150 These inits are used during saving and each time a new element is added.
2154 =item C<init_makemodels>
2156 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
2157 $self->part->makemodels, ready to be saved.
2159 Used for saving parts and adding new makemodel rows.
2161 =item C<parse_add_items_to_objects PART_TYPE>
2163 Parses the resulting form from either the part-picker submit or the multi-item
2164 submit, and creates an arrayref of assortment_item or assembly objects, that
2165 can be rendered via C<render_assortment_items_to_html> or
2166 C<render_assembly_items_to_html>.
2168 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
2169 Optional param: position (used for numbering and listrow class)
2171 =item C<render_assortment_items_to_html ITEM_OBJECTS>
2173 Takes an array_ref of assortment_items, and generates tables rows ready for
2174 adding to the assortment table. Is used when a part is loaded, or whenever new
2175 assortment items are added.
2177 =item C<parse_form_makemodels>
2179 Makemodels can't just be overwritten, because of the field "lastupdate", that
2180 remembers when the lastcost for that vendor changed the last time.
2182 So the original values are cloned and remembered, so we can compare if lastcost
2183 was changed in $::form, and keep or update lastupdate.
2185 lastcost isn't updated until the first time it was saved with a value, until
2188 Also a boolean "makemodel" needs to be written in parts, depending on whether
2189 makemodel entries exist or not.
2191 We still need init_makemodels for when we open the part for editing.
2201 It should be possible to jump to the edit page in a specific tab
2205 Support callbacks, e.g. creating a new part from within an order, and jumping
2206 back to the order again afterwards.
2210 Support units when adding assembly items or assortment items. Currently the
2211 default unit of the item is always used.
2215 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
2216 consists of other assemblies.
2222 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>