1 package SL::Controller::Part;
4 use parent qw(SL::Controller::Base);
8 use SL::Controller::Helper::GetModels;
9 use SL::Locale::String qw(t8);
11 use List::Util qw(sum);
12 use SL::Helper::Flash;
19 use Rose::Object::MakeMethods::Generic (
20 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
23 assortment assortment_items assembly assembly_items
24 all_pricegroups all_translations all_partsgroups all_units
25 all_buchungsgruppen all_payment_terms all_warehouses
26 all_languages all_units all_price_factors) ],
27 'scalar' => [ qw(warehouse bin) ],
31 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
32 except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
34 __PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);
36 # actions for editing parts
39 my ($self, %params) = @_;
41 $self->part( SL::DB::Part->new_part );
45 sub action_add_service {
46 my ($self, %params) = @_;
48 $self->part( SL::DB::Part->new_service );
52 sub action_add_assembly {
53 my ($self, %params) = @_;
55 $self->part( SL::DB::Part->new_assembly );
59 sub action_add_assortment {
60 my ($self, %params) = @_;
62 $self->part( SL::DB::Part->new_assortment );
69 check_has_valid_part_type($::form->{part_type});
71 $self->action_add_part if $::form->{part_type} eq 'part';
72 $self->action_add_service if $::form->{part_type} eq 'service';
73 $self->action_add_assembly if $::form->{part_type} eq 'assembly';
74 $self->action_add_assortment if $::form->{part_type} eq 'assortment';
78 my ($self, %params) = @_;
80 # checks that depend only on submitted $::form
81 $self->check_form or return $self->js->render;
83 my $is_new = !$self->part->id; # $ part gets loaded here
85 # check that the part hasn't been modified
87 $self->check_part_not_modified or
88 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;
91 if ( $is_new and !$::form->{part}{partnumber} ) {
92 $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
97 my @errors = $self->part->validate;
98 return $self->js->error(@errors)->render if @errors;
100 # $self->part has been loaded, parsed and validated without errors and is ready to be saved
101 $self->part->db->with_transaction(sub {
103 if ( $params{save_as_new} ) {
104 $self->part( $self->part->clone_and_reset_deep );
105 $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
108 $self->part->save(cascade => 1);
110 SL::DB::History->new(
111 trans_id => $self->part->id,
112 snumbers => 'partnumber_' . $self->part->partnumber,
113 employee_id => SL::DB::Manager::Employee->current->id,
118 CVar->save_custom_variables(
119 dbh => $self->part->db->dbh,
121 trans_id => $self->part->id,
122 variables => $::form, # $::form->{cvar} would be nicer
127 }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
129 flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
131 # reload item, this also resets last_modification!
132 $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
135 sub action_save_as_new {
137 $self->action_save(save_as_new=>1);
143 my $db = $self->part->db; # $self->part has a get_set_init on $::form
145 my $partnumber = $self->part->partnumber; # remember for history log
150 # delete part, together with relationships that don't already
151 # have an ON DELETE CASCADE, e.g. makemodel and translation.
152 $self->part->delete(cascade => 1);
154 SL::DB::History->new(
155 trans_id => $self->part->id,
156 snumbers => 'partnumber_' . $partnumber,
157 employee_id => SL::DB::Manager::Employee->current->id,
159 addition => 'DELETED',
162 }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
164 flash_later('info', t8('The item has been deleted.'));
165 my @redirect_params = (
166 controller => 'controller.pl',
167 action => 'LoginScreen/user_login'
169 $self->redirect_to(@redirect_params);
172 sub action_use_as_new {
173 my ($self, %params) = @_;
175 my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
176 $::form->{oldpartnumber} = $oldpart->partnumber;
178 $self->part($oldpart->clone_and_reset_deep);
180 $self->part->partnumber(undef);
186 my ($self, %params) = @_;
192 my ($self, %params) = @_;
194 $self->_set_javascript;
196 my (%assortment_vars, %assembly_vars);
197 %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
198 %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;
200 $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
202 CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
203 if (scalar @{ $params{CUSTOM_VARIABLES} });
205 my %title_hash = ( part => t8('Edit Part'),
206 assembly => t8('Edit Assembly'),
207 service => t8('Edit Service'),
208 assortment => t8('Edit Assortment'),
211 $self->part->prices([]) unless $self->part->prices;
212 $self->part->translations([]) unless $self->part->translations;
216 title => $title_hash{$self->part->part_type},
217 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
220 translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
221 prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
222 oldpartnumber => $::form->{oldpartnumber},
223 old_id => $::form->{old_id},
231 my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
232 $_[0]->render('part/history', { layout => 0 },
233 history_entries => $history_entries);
236 sub action_update_item_totals {
239 my $part_type = $::form->{part_type};
240 die unless $part_type =~ /^(assortment|assembly)$/;
242 my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
243 my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
245 my $sum_diff = $sellprice_sum-$lastcost_sum;
248 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
249 ->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
250 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
251 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
252 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
256 sub action_add_multi_assortment_items {
259 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
260 my $html = $self->render_assortment_items_to_html($item_objects);
262 $self->js->run('kivi.Part.close_multi_items_dialog')
263 ->append('#assortment_rows', $html)
264 ->run('kivi.Part.renumber_positions')
265 ->run('kivi.Part.assortment_recalc')
269 sub action_add_multi_assembly_items {
272 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
273 my $html = $self->render_assembly_items_to_html($item_objects);
275 $self->js->run('kivi.Part.close_multi_items_dialog')
276 ->append('#assembly_rows', $html)
277 ->run('kivi.Part.renumber_positions')
278 ->run('kivi.Part.assembly_recalc')
282 sub action_add_assortment_item {
283 my ($self, %params) = @_;
285 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
287 carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
289 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
290 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
291 return $self->js->flash('error', t8("This part has already been added."))->render;
294 my $number_of_items = scalar @{$self->assortment_items};
295 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
296 my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);
298 push(@{$self->assortment_items}, @{$item_objects});
299 my $part = SL::DB::Part->new(part_type => 'assortment');
300 $part->assortment_items(@{$self->assortment_items});
301 my $items_sellprice_sum = $part->items_sellprice_sum;
302 my $items_lastcost_sum = $part->items_lastcost_sum;
303 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
306 ->append('#assortment_rows' , $html) # append in tbody
307 ->val('.add_assortment_item_input' , '')
308 ->run('kivi.Part.focus_last_assortment_input')
309 ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
310 ->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
311 ->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
312 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
313 ->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
316 sub action_add_assembly_item {
319 validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
321 carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
323 my $add_item_id = $::form->{add_items}->[0]->{parts_id};
324 my $duplicate_warning = 0; # duplicates are allowed, just warn
325 if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
326 $duplicate_warning++;
329 my $number_of_items = scalar @{$self->assembly_items};
330 my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
331 my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);
333 $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
335 push(@{$self->assembly_items}, @{$item_objects});
336 my $part = SL::DB::Part->new(part_type => 'assembly');
337 $part->assemblies(@{$self->assembly_items});
338 my $items_sellprice_sum = $part->items_sellprice_sum;
339 my $items_lastcost_sum = $part->items_lastcost_sum;
340 my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
343 ->append('#assembly_rows', $html) # append in tbody
344 ->val('.add_assembly_item_input' , '')
345 ->run('kivi.Part.focus_last_assembly_input')
346 ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
347 ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
348 ->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
349 ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
350 ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
354 sub action_show_multi_items_dialog {
355 require SL::DB::PartsGroup;
356 $_[0]->render('part/_multi_items_dialog', { layout => 0 },
357 part_type => 'assortment',
358 partfilter => '', # can I get at the current input of the partpicker here?
359 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
362 sub action_multi_items_update_result {
365 $::form->{multi_items}->{filter}->{obsolete} = 0;
367 my $count = $_[0]->multi_items_models->count;
370 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
371 $_[0]->render($text, { layout => 0 });
372 } elsif ($count > $max_count) {
373 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
374 $_[0]->render($text, { layout => 0 });
376 my $multi_items = $_[0]->multi_items_models->get;
377 $_[0]->render('part/_multi_items_result', { layout => 0 },
378 multi_items => $multi_items);
382 sub action_add_makemodel_row {
385 my $vendor_id = $::form->{add_makemodel};
387 my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
388 return $self->js->error(t8("No vendor selected or found!"))->render;
390 if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
391 $self->js->flash('info', t8("This vendor has already been added."));
394 my $position = scalar @{$self->makemodels} + 1;
396 my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
400 sortorder => $position,
401 ) or die "Can't create MakeModel object";
403 my $row_as_html = $self->p->render('part/_makemodel_row',
405 listrow => $position % 2 ? 0 : 1,
408 # after selection focus on the model field in the row that was just added
410 ->append('#makemodel_rows', $row_as_html) # append in tbody
411 ->val('.add_makemodel_input', '')
412 ->run('kivi.Part.focus_last_makemodel_input')
416 sub action_reorder_items {
419 my $part_type = $::form->{part_type};
422 partnumber => sub { $_[0]->part->partnumber },
423 description => sub { $_[0]->part->description },
424 qty => sub { $_[0]->qty },
425 sellprice => sub { $_[0]->part->sellprice },
426 lastcost => sub { $_[0]->part->lastcost },
427 partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
430 my $method = $sort_keys{$::form->{order_by}};
433 if ($part_type eq 'assortment') {
434 @items = @{ $self->assortment_items };
436 @items = @{ $self->assembly_items };
439 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
440 if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
441 if ($::form->{sort_dir}) {
442 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
444 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
447 if ($::form->{sort_dir}) {
448 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
450 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
454 $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
457 sub action_warehouse_changed {
460 if ($::form->{warehouse_id} ) {
461 $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
462 die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
464 if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
465 $self->bin($self->warehouse->bins->[0]);
467 ->html('#bin', $self->build_bin_select)
468 ->focus('#part_bin_id');
469 return $self->js->render;
473 # no warehouse was selected, empty the bin field and reset the id
475 ->val('#part_bin_id', undef)
478 return $self->js->render;
481 sub action_ajax_autocomplete {
482 my ($self, %params) = @_;
484 # if someone types something, and hits enter, assume he entered the full name.
485 # if something matches, treat that as sole match
486 # unfortunately get_models can't do more than one per package atm, so we d it
487 # the oldfashioned way.
488 if ($::form->{prefer_exact}) {
490 if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
493 SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
495 description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
496 partnumber => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
501 $self->parts($exact_matches);
507 value => $_->displayable_name,
508 label => $_->displayable_name,
510 partnumber => $_->partnumber,
511 description => $_->description,
512 part_type => $_->part_type,
514 cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
516 } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
518 $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
521 sub action_test_page {
522 $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
525 sub action_part_picker_search {
526 $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
529 sub action_part_picker_result {
530 $_[0]->render('part/_part_picker_result', { layout => 0 });
536 if ($::request->type eq 'json') {
541 $part_hash = $self->part->as_tree;
542 $part_hash->{cvars} = $self->part->cvar_as_hashref;
545 $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
550 sub validate_add_items {
551 scalar @{$::form->{add_items}};
554 sub prepare_assortment_render_vars {
557 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
558 items_lastcost_sum => $self->part->items_lastcost_sum,
559 assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
561 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
566 sub prepare_assembly_render_vars {
569 my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
570 items_lastcost_sum => $self->part->items_lastcost_sum,
571 assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
573 $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
581 check_has_valid_part_type($self->part->part_type);
583 $self->_set_javascript;
585 my %title_hash = ( part => t8('Add Part'),
586 assembly => t8('Add Assembly'),
587 service => t8('Add Service'),
588 assortment => t8('Add Assortment'),
593 title => $title_hash{$self->part->part_type},
594 show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
599 sub _set_javascript {
601 $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
602 $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
605 sub recalc_item_totals {
606 my ($self, %params) = @_;
608 if ( $params{part_type} eq 'assortment' ) {
609 return 0 unless scalar @{$self->assortment_items};
610 } elsif ( $params{part_type} eq 'assembly' ) {
611 return 0 unless scalar @{$self->assembly_items};
613 carp "can only calculate sum for assortments and assemblies";
616 my $part = SL::DB::Part->new(part_type => $params{part_type});
617 if ( $part->is_assortment ) {
618 $part->assortment_items( @{$self->assortment_items} );
619 if ( $params{price_type} eq 'lastcost' ) {
620 return $part->items_lastcost_sum;
622 if ( $params{pricegroup_id} ) {
623 return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
625 return $part->items_sellprice_sum;
628 } elsif ( $part->is_assembly ) {
629 $part->assemblies( @{$self->assembly_items} );
630 if ( $params{price_type} eq 'lastcost' ) {
631 return $part->items_lastcost_sum;
633 return $part->items_sellprice_sum;
638 sub check_part_not_modified {
641 return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
648 my $is_new = !$self->part->id;
650 my $params = delete($::form->{part}) || { };
652 delete $params->{id};
653 # never overwrite existing partnumber, should be a read-only field anyway
654 delete $params->{partnumber} if $self->part->partnumber;
655 $self->part->assign_attributes(%{ $params});
656 $self->part->bin_id(undef) unless $self->part->warehouse_id;
658 # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
659 # will be the case for used assortments when saving, or when a used assortment
661 if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
662 $self->part->assortment_items([]);
663 $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
666 if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
667 $self->part->assemblies([]); # completely rewrite assortments each time
668 $self->part->add_assemblies( @{ $self->assembly_items } );
671 $self->part->translations([]);
672 $self->parse_form_translations;
674 $self->part->prices([]);
675 $self->parse_form_prices;
677 $self->parse_form_makemodels;
680 sub parse_form_prices {
682 # only save prices > 0
683 my $prices = delete($::form->{prices}) || [];
684 foreach my $price ( @{$prices} ) {
685 my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
686 next unless $sellprice > 0; # skip negative prices as well
687 my $p = SL::DB::Price->new(parts_id => $self->part->id,
688 pricegroup_id => $price->{pricegroup_id},
691 $self->part->add_prices($p);
695 sub parse_form_translations {
697 # don't add empty translations
698 my $translations = delete($::form->{translations}) || [];
699 foreach my $translation ( @{$translations} ) {
700 next unless $translation->{translation};
701 my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
702 $self->part->add_translations( $translation );
706 sub parse_form_makemodels {
710 if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
711 $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
714 $self->part->makemodels([]);
717 my $makemodels = delete($::form->{makemodels}) || [];
718 foreach my $makemodel ( @{$makemodels} ) {
719 next unless $makemodel->{make};
721 my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
723 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
724 id => $makemodel->{id},
725 make => $makemodel->{make},
726 model => $makemodel->{model} || '',
727 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
728 sortorder => $position,
730 if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
731 # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
732 # don't change lastupdate
733 } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
734 # new makemodel, no lastcost entered, leave lastupdate empty
735 } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
736 # lastcost hasn't changed, use original lastupdate
737 $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
739 $mm->lastupdate(DateTime->now);
741 $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
742 $self->part->add_makemodels($mm);
746 sub build_bin_select {
747 $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
748 title_key => 'description',
749 default => $_[0]->bin->id,
753 # get_set_inits for partpicker
756 if ($::form->{no_paginate}) {
757 $_[0]->models->disable_plugin('paginated');
763 # get_set_inits for part controller
767 # used by edit, save, delete and add
769 if ( $::form->{part}{id} ) {
770 return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
772 die "part_type missing" unless $::form->{part}{part_type};
773 return SL::DB::Part->new(part_type => $::form->{part}{part_type});
779 return $self->part->orphaned;
785 SL::Controller::Helper::GetModels->new(
792 partnumber => t8('Partnumber'),
793 description => t8('Description'),
795 with_objects => [ qw(unit_obj) ],
804 sub init_assortment_items {
805 # this init is used while saving and whenever assortments change dynamically
809 my $assortment_items = delete($::form->{assortment_items}) || [];
810 foreach my $assortment_item ( @{$assortment_items} ) {
811 next unless $assortment_item->{parts_id};
813 my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
814 my $ai = SL::DB::AssortmentItem->new( parts_id => $part->id,
815 qty => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
816 charge => $assortment_item->{charge},
817 unit => $assortment_item->{unit} || $part->unit,
818 position => $position,
826 sub init_makemodels {
830 my @makemodel_array = ();
831 my $makemodels = delete($::form->{makemodels}) || [];
833 foreach my $makemodel ( @{$makemodels} ) {
834 next unless $makemodel->{make};
836 my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
837 id => $makemodel->{id},
838 make => $makemodel->{make},
839 model => $makemodel->{model} || '',
840 lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
841 sortorder => $position,
842 ) or die "Can't create mm";
843 # $mm->id($makemodel->{id}) if $makemodel->{id};
844 push(@makemodel_array, $mm);
846 return \@makemodel_array;
849 sub init_assembly_items {
853 my $assembly_items = delete($::form->{assembly_items}) || [];
854 foreach my $assembly_item ( @{$assembly_items} ) {
855 next unless $assembly_item->{parts_id};
857 my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
858 my $ai = SL::DB::Assembly->new(parts_id => $part->id,
859 bom => $assembly_item->{bom},
860 qty => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
861 position => $position,
868 sub init_all_warehouses {
870 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
873 sub init_all_languages {
874 SL::DB::Manager::Language->get_all_sorted;
877 sub init_all_partsgroups {
879 SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
882 sub init_all_buchungsgruppen {
884 if ( $self->part->orphaned ) {
885 return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
887 return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
893 if ( $self->part->orphaned ) {
894 return SL::DB::Manager::Unit->get_all_sorted;
896 return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
900 sub init_all_payment_terms {
902 SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
905 sub init_all_price_factors {
906 SL::DB::Manager::PriceFactor->get_all_sorted;
909 sub init_all_pricegroups {
910 SL::DB::Manager::Pricegroup->get_all_sorted;
913 # model used to filter/display the parts in the multi-items dialog
914 sub init_multi_items_models {
915 SL::Controller::Helper::GetModels->new(
918 with_objects => [ qw(unit_obj partsgroup) ],
919 disable_plugin => 'paginated',
920 source => $::form->{multi_items},
926 partnumber => t8('Partnumber'),
927 description => t8('Description')}
931 # simple checks to run on $::form before saving
933 sub form_check_part_description_exists {
936 return 1 if $::form->{part}{description};
938 $self->js->flash('error', t8('Part Description missing!'))
939 ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
940 ->focus('#part_description');
944 sub form_check_assortment_items_exist {
947 return 1 unless $::form->{part}{part_type} eq 'assortment';
948 # skip check for existing parts that have been used
949 return 1 if ($self->part->id and !$self->part->orphaned);
951 # new or orphaned parts must have items in $::form->{assortment_items}
952 unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
953 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
954 ->focus('#add_assortment_item_name')
955 ->flash('error', t8('The assortment doesn\'t have any items.'));
961 sub form_check_assortment_items_unique {
964 return 1 unless $::form->{part}{part_type} eq 'assortment';
966 my %duplicate_elements;
968 for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
969 $duplicate_elements{$_}++ if $count{$_}++;
972 if ( keys %duplicate_elements ) {
973 $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
974 ->flash('error', t8('There are duplicate assortment items'));
980 sub form_check_assembly_items_exist {
983 return 1 unless $::form->{part}->{part_type} eq 'assembly';
985 unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
986 $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
987 ->focus('#add_assembly_item_name')
988 ->flash('error', t8('The assembly doesn\'t have any items.'));
994 sub form_check_partnumber_is_unique {
997 if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
998 my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1000 $self->js->flash('error', t8('The partnumber already exists!'))
1001 ->focus('#part_description');
1008 # general checking functions
1009 sub check_next_transnumber_is_free {
1012 my ($next_transnumber, $count);
1013 $self->part->db->with_transaction(sub {
1014 $next_transnumber = $self->part->get_next_trans_number;
1015 $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1018 $count ? return 0 : return 1;
1022 die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1028 $self->form_check_part_description_exists || return 0;
1029 $self->form_check_assortment_items_exist || return 0;
1030 $self->form_check_assortment_items_unique || return 0;
1031 $self->form_check_assembly_items_exist || return 0;
1032 $self->form_check_partnumber_is_unique || return 0;
1037 sub check_has_valid_part_type {
1038 die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1041 sub render_assortment_items_to_html {
1042 my ($self, $assortment_items, $number_of_items) = @_;
1044 my $position = $number_of_items + 1;
1046 foreach my $ai (@$assortment_items) {
1047 $html .= $self->p->render('part/_assortment_row',
1048 PART => $self->part,
1049 orphaned => $self->orphaned,
1051 listrow => $position % 2 ? 1 : 0,
1052 position => $position, # for legacy assemblies
1059 sub render_assembly_items_to_html {
1060 my ($self, $assembly_items, $number_of_items) = @_;
1062 my $position = $number_of_items + 1;
1064 foreach my $ai (@{$assembly_items}) {
1065 $html .= $self->p->render('part/_assembly_row',
1066 PART => $self->part,
1067 orphaned => $self->orphaned,
1069 listrow => $position % 2 ? 1 : 0,
1070 position => $position, # for legacy assemblies
1077 sub parse_add_items_to_objects {
1078 my ($self, %params) = @_;
1079 my $part_type = $params{part_type};
1080 die unless $params{part_type} =~ /^(assortment|assembly)$/;
1081 my $position = $params{position} || 1;
1083 my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1086 foreach my $item ( @add_items ) {
1087 my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1089 if ( $part_type eq 'assortment' ) {
1090 $ai = SL::DB::AssortmentItem->new(part => $part,
1091 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1092 unit => $part->unit, # TODO: $item->{unit} || $part->unit
1093 position => $position,
1094 ) or die "Can't create AssortmentItem from item";
1095 } elsif ( $part_type eq 'assembly' ) {
1096 $ai = SL::DB::Assembly->new(parts_id => $part->id,
1097 # id => $self->assembly->id, # will be set on save
1098 qty => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1099 bom => 0, # default when adding: no bom
1100 position => $position,
1103 die "part_type must be assortment or assembly";
1105 push(@item_objects, $ai);
1109 return \@item_objects;
1120 SL::Controller::Part - Part CRUD controller
1124 Controller for adding/editing/saving/deleting parts.
1126 All the relations are loaded at once and saving the part, adding a history
1127 entry and saving CVars happens inside one transaction. When saving the old
1128 relations are deleted and written as new to the database.
1130 Relations for parts:
1138 =item assembly items
1140 =item assortment items
1148 There are 4 different part types:
1154 The "default" part type.
1156 inventory_accno_id is set.
1160 Services can't be stocked.
1162 inventory_accno_id isn't set.
1166 Assemblies consist of other parts, services, assemblies or assortments. They
1167 aren't meant to be bought, only sold. To add assemblies to stock you typically
1168 have to make them, which reduces the stock by its respective components. Once
1169 an assembly item has been created there is currently no way to "disassemble" it
1170 again. An assembly item can appear several times in one assembly. An assmbly is
1171 sold as one item with a defined sellprice and lastcost. If the component prices
1172 change the assortment price remains the same. The assembly items may be printed
1173 in a record if the item's "bom" is set.
1177 Similar to assembly, but each assortment item may only appear once per
1178 assortment. When selling an assortment the assortment items are added to the
1179 record together with the assortment, which is added with sellprice 0.
1181 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1182 determined by the sum of the current assortment item prices when the assortment
1183 is added to a record. This also means that price rules and customer discounts
1184 will be applied to the assortment items.
1186 Once the assortment items have been added they may be modified or deleted, just
1187 as if they had been added manually, the individual assortment items aren't
1188 linked to the assortment or the other assortment items in any way.
1196 =item C<action_add_part>
1198 =item C<action_add_service>
1200 =item C<action_add_assembly>
1202 =item C<action_add_assortment>
1204 =item C<action_add PART_TYPE>
1206 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1207 parameter part_type as an action. Example:
1209 controller.pl?action=Part/add&part_type=service
1211 =item C<action_save>
1213 Saves the current part and then reloads the edit page for the part.
1215 =item C<action_use_as_new>
1217 Takes the information from the current part, plus any modifications made on the
1218 page, and creates a new edit page that is ready to be saved. The partnumber is
1219 set empty, so a new partnumber from the number range will be used if the user
1220 doesn't enter one manually.
1222 Unsaved changes to the original part aren't updated.
1224 The part type cannot be changed in this way.
1226 =item C<action_delete>
1228 Deletes the current part and then redirects to the main page, there is no
1231 The delete button only appears if the part is 'orphaned', according to
1232 SL::DB::Part orphaned.
1234 The part can't be deleted if it appears in invoices, orders, delivery orders,
1235 the inventory, or is part of an assembly or assortment.
1237 If the part is deleted its relations prices, makdemodel, assembly,
1238 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1240 Before this controller items that appeared in inventory didn't count as
1241 orphaned and could be deleted and the inventory entries were also deleted, this
1242 "feature" hasn't been implemented.
1244 =item C<action_edit part.id>
1246 Load and display a part for editing.
1248 controller.pl?action=Part/edit&part.id=12345
1250 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1254 =head1 BUTTON ACTIONS
1260 Opens a popup displaying all the history entries. Once a new history controller
1261 is written the button could link there instead, with the part already selected.
1269 =item C<action_update_item_totals>
1271 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1272 amount of an item changes. The sum of all sellprices and lastcosts is
1273 calculated and the totals updated. Uses C<recalc_item_totals>.
1275 =item C<action_add_assortment_item>
1277 Adds a new assortment item from a part picker seleciton to the assortment item list
1279 If the item already exists in the assortment the item isn't added and a Flash
1282 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1283 after adding each new item, add the new object to the item objects that were
1284 already parsed, calculate totals via a dummy part then update the row and the
1287 =item C<action_add_assembly_item>
1289 Adds a new assembly item from a part picker seleciton to the assembly item list
1291 If the item already exists in the assembly a flash info is generated, but the
1294 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1295 after adding each new item, add the new object to the item objects that were
1296 already parsed, calculate totals via a dummy part then update the row and the
1299 =item C<action_add_multi_assortment_items>
1301 Parses the items to be added from the form generated by the multi input and
1302 appends the html of the tr-rows to the assortment item table. Afterwards all
1303 assortment items are renumbered and the sums recalculated via
1304 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1306 =item C<action_add_multi_assembly_items>
1308 Parses the items to be added from the form generated by the multi input and
1309 appends the html of the tr-rows to the assembly item table. Afterwards all
1310 assembly items are renumbered and the sums recalculated via
1311 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1313 =item C<action_show_multi_items_dialog>
1315 =item C<action_multi_items_update_result>
1317 =item C<action_add_makemodel_row>
1319 Add a new makemodel row with the vendor that was selected via the vendor
1322 Checks the already existing makemodels and warns if a row with that vendor
1323 already exists. Currently it is possible to have duplicate vendor rows.
1325 =item C<action_reorder_items>
1327 Sorts the item table for assembly or assortment items.
1329 =item C<action_warehouse_changed>
1333 =head1 ACTIONS part picker
1337 =item C<action_ajax_autocomplete>
1339 =item C<action_test_page>
1341 =item C<action_part_picker_search>
1343 =item C<action_part_picker_result>
1345 =item C<action_show>
1355 Calls some simple checks that test the submitted $::form for obvious errors.
1356 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1358 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1359 some cases extra actions are taken, e.g. if the part description is missing the
1360 basic data tab is selected and the description input field is focussed.
1366 =item C<form_check_part_description_exists>
1368 =item C<form_check_assortment_items_exist>
1370 =item C<form_check_assortment_items_unique>
1372 =item C<form_check_assembly_items_exist>
1374 =item C<form_check_partnumber_is_unique>
1378 =head1 HELPER FUNCTIONS
1384 When submitting the form for saving, parses the transmitted form. Expects the
1388 $::form->{makemodels}
1389 $::form->{translations}
1391 $::form->{assemblies}
1392 $::form->{assortments}
1394 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1396 =item C<recalc_item_totals %params>
1398 Helper function for calculating the total lastcost and sellprice for assemblies
1399 or assortments according to their items, which are parsed from the current
1402 Is called whenever the qty of an item is changed or items are deleted.
1406 * part_type : 'assortment' or 'assembly' (mandatory)
1408 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1410 Depending on the price_type the lastcost sum or sellprice sum is returned.
1412 Doesn't work for recursive items.
1416 =head1 GET SET INITS
1418 There are get_set_inits for
1426 which parse $::form and automatically create an array of objects.
1428 These inits are used during saving and each time a new element is added.
1432 =item C<init_makemodels>
1434 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1435 $self->part->makemodels, ready to be saved.
1437 Used for saving parts and adding new makemodel rows.
1439 =item C<parse_add_items_to_objects PART_TYPE>
1441 Parses the resulting form from either the part-picker submit or the multi-item
1442 submit, and creates an arrayref of assortment_item or assembly objects, that
1443 can be rendered via C<render_assortment_items_to_html> or
1444 C<render_assembly_items_to_html>.
1446 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1447 Optional param: position (used for numbering and listrow class)
1449 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1451 Takes an array_ref of assortment_items, and generates tables rows ready for
1452 adding to the assortment table. Is used when a part is loaded, or whenever new
1453 assortment items are added.
1455 =item C<parse_form_makemodels>
1457 Makemodels can't just be overwritten, because of the field "lastupdate", that
1458 remembers when the lastcost for that vendor changed the last time.
1460 So the original values are cloned and remembered, so we can compare if lastcost
1461 was changed in $::form, and keep or update lastupdate.
1463 lastcost isn't updated until the first time it was saved with a value, until
1466 Also a boolean "makemodel" needs to be written in parts, depending on whether
1467 makemodel entries exist or not.
1469 We still need init_makemodels for when we open the part for editing.
1479 It should be possible to jump to the edit page in a specific tab
1483 Support callbacks, e.g. creating a new part from within an order, and jumping
1484 back to the order again afterwards.
1488 Support units when adding assembly items or assortment items. Currently the
1489 default unit of the item is always used.
1493 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1494 consists of other assemblies.
1500 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>