Part Controller - Artikelnummer von unbenutzten Artikeln ändern
[kivitendo-erp.git] / SL / Controller / Part.pm
1 package SL::Controller::Part;
2
3 use strict;
4 use parent qw(SL::Controller::Base);
5
6 use Clone qw(clone);
7 use SL::DB::Part;
8 use SL::Controller::Helper::GetModels;
9 use SL::Locale::String qw(t8);
10 use SL::JSON;
11 use List::Util qw(sum);
12 use SL::Helper::Flash;
13 use Data::Dumper;
14 use DateTime;
15 use SL::DB::History;
16 use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
17 use SL::CVar;
18 use Carp;
19
20 use Rose::Object::MakeMethods::Generic (
21   'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
22                                   makemodels
23                                   orphaned
24                                   assortment assortment_items assembly assembly_items
25                                   all_pricegroups all_translations all_partsgroups all_units
26                                   all_buchungsgruppen all_payment_terms all_warehouses
27                                   all_languages all_units all_price_factors) ],
28   'scalar'                => [ qw(warehouse bin) ],
29 );
30
31 # safety
32 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
33                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
34
35 __PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
36
37 # actions for editing parts
38 #
39 sub action_add_part {
40   my ($self, %params) = @_;
41
42   $self->part( SL::DB::Part->new_part );
43   $self->add;
44 };
45
46 sub action_add_service {
47   my ($self, %params) = @_;
48
49   $self->part( SL::DB::Part->new_service );
50   $self->add;
51 };
52
53 sub action_add_assembly {
54   my ($self, %params) = @_;
55
56   $self->part( SL::DB::Part->new_assembly );
57   $self->add;
58 };
59
60 sub action_add_assortment {
61   my ($self, %params) = @_;
62
63   $self->part( SL::DB::Part->new_assortment );
64   $self->add;
65 };
66
67 sub action_add {
68   my ($self) = @_;
69
70   check_has_valid_part_type($::form->{part_type});
71
72   $self->action_add_part       if $::form->{part_type} eq 'part';
73   $self->action_add_service    if $::form->{part_type} eq 'service';
74   $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
75   $self->action_add_assortment if $::form->{part_type} eq 'assortment';
76 };
77
78 sub action_save {
79   my ($self, %params) = @_;
80
81   # checks that depend only on submitted $::form
82   $self->check_form or return $self->js->render;
83
84   my $is_new = !$self->part->id; # $ part gets loaded here
85
86   # check that the part hasn't been modified
87   unless ( $is_new ) {
88     $self->check_part_not_modified or
89       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;
90   }
91
92   if ( $is_new and !$::form->{part}{partnumber} ) {
93     $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
94   }
95
96   $self->parse_form;
97
98   my @errors = $self->part->validate;
99   return $self->js->error(@errors)->render if @errors;
100
101   # $self->part has been loaded, parsed and validated without errors and is ready to be saved
102   $self->part->db->with_transaction(sub {
103
104     if ( $params{save_as_new} ) {
105       $self->part( $self->part->clone_and_reset_deep );
106       $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
107     };
108
109     $self->part->save(cascade => 1);
110
111     SL::DB::History->new(
112       trans_id    => $self->part->id,
113       snumbers    => 'partnumber_' . $self->part->partnumber,
114       employee_id => SL::DB::Manager::Employee->current->id,
115       what_done   => 'part',
116       addition    => 'SAVED',
117     )->save();
118
119     CVar->save_custom_variables(
120         dbh          => $self->part->db->dbh,
121         module       => 'IC',
122         trans_id     => $self->part->id,
123         variables    => $::form, # $::form->{cvar} would be nicer
124         always_valid => 1,
125     );
126
127     1;
128   }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
129
130   flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
131
132   # reload item, this also resets last_modification!
133   $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
134 }
135
136 sub action_save_as_new {
137   my ($self) = @_;
138   $self->action_save(save_as_new=>1);
139 }
140
141 sub action_delete {
142   my ($self) = @_;
143
144   my $db = $self->part->db; # $self->part has a get_set_init on $::form
145
146   my $partnumber = $self->part->partnumber; # remember for history log
147
148   $db->do_transaction(
149     sub {
150
151       # delete part, together with relationships that don't already
152       # have an ON DELETE CASCADE, e.g. makemodel and translation.
153       $self->part->delete(cascade => 1);
154
155       SL::DB::History->new(
156         trans_id    => $self->part->id,
157         snumbers    => 'partnumber_' . $partnumber,
158         employee_id => SL::DB::Manager::Employee->current->id,
159         what_done   => 'part',
160         addition    => 'DELETED',
161       )->save();
162       1;
163   }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
164
165   flash_later('info', t8('The item has been deleted.'));
166   my @redirect_params = (
167     controller => 'controller.pl',
168     action => 'LoginScreen/user_login'
169   );
170   $self->redirect_to(@redirect_params);
171 }
172
173 sub action_use_as_new {
174   my ($self, %params) = @_;
175
176   my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
177   $::form->{oldpartnumber} = $oldpart->partnumber;
178
179   $self->part($oldpart->clone_and_reset_deep);
180   $self->parse_form;
181   $self->part->partnumber(undef);
182
183   $self->render_form;
184 }
185
186 sub action_edit {
187   my ($self, %params) = @_;
188
189   $self->render_form;
190 }
191
192 sub render_form {
193   my ($self, %params) = @_;
194
195   $self->_set_javascript;
196
197   my (%assortment_vars, %assembly_vars);
198   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
199   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
200
201   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
202
203   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
204     if (scalar @{ $params{CUSTOM_VARIABLES} });
205
206   my %title_hash = ( part       => t8('Edit Part'),
207                      assembly   => t8('Edit Assembly'),
208                      service    => t8('Edit Service'),
209                      assortment => t8('Edit Assortment'),
210                    );
211
212   $self->part->prices([])       unless $self->part->prices;
213   $self->part->translations([]) unless $self->part->translations;
214
215   $self->render(
216     'part/form',
217     title             => $title_hash{$self->part->part_type},
218     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
219     %assortment_vars,
220     %assembly_vars,
221     translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
222     prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
223     oldpartnumber     => $::form->{oldpartnumber},
224     old_id            => $::form->{old_id},
225     %params,
226   );
227 }
228
229 sub action_history {
230   my ($self) = @_;
231
232   my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
233   $_[0]->render('part/history', { layout => 0 },
234                                   history_entries => $history_entries);
235 }
236
237 sub action_update_item_totals {
238   my ($self) = @_;
239
240   my $part_type = $::form->{part_type};
241   die unless $part_type =~ /^(assortment|assembly)$/;
242
243   my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
244   my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
245
246   my $sum_diff      = $sellprice_sum-$lastcost_sum;
247
248   $self->js
249     ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
250     ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
251     ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
252     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
253     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
254     ->no_flash_clear->render();
255 }
256
257 sub action_add_multi_assortment_items {
258   my ($self) = @_;
259
260   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
261   my $html         = $self->render_assortment_items_to_html($item_objects);
262
263   $self->js->run('kivi.Part.close_multi_items_dialog')
264            ->append('#assortment_rows', $html)
265            ->run('kivi.Part.renumber_positions')
266            ->run('kivi.Part.assortment_recalc')
267            ->render();
268 }
269
270 sub action_add_multi_assembly_items {
271   my ($self) = @_;
272
273   my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
274   my @checked_objects;
275   foreach my $item (@{$item_objects}) {
276     my $errstr = validate_assembly($item->part,$self->part);
277     $self->js->flash('error',$errstr) if     $errstr;
278     push (@checked_objects,$item)     unless $errstr;
279   }
280
281   my $html = $self->render_assembly_items_to_html(\@checked_objects);
282
283   $self->js->run('kivi.Part.close_multi_items_dialog')
284            ->append('#assembly_rows', $html)
285            ->run('kivi.Part.renumber_positions')
286            ->run('kivi.Part.assembly_recalc')
287            ->render();
288 }
289
290 sub action_add_assortment_item {
291   my ($self, %params) = @_;
292
293   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
294
295   carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
296
297   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
298   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
299     return $self->js->flash('error', t8("This part has already been added."))->render;
300   };
301
302   my $number_of_items = scalar @{$self->assortment_items};
303   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
304   my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
305
306   push(@{$self->assortment_items}, @{$item_objects});
307   my $part = SL::DB::Part->new(part_type => 'assortment');
308   $part->assortment_items(@{$self->assortment_items});
309   my $items_sellprice_sum = $part->items_sellprice_sum;
310   my $items_lastcost_sum  = $part->items_lastcost_sum;
311   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
312
313   $self->js
314     ->append('#assortment_rows'        , $html)  # append in tbody
315     ->val('.add_assortment_item_input' , '')
316     ->run('kivi.Part.focus_last_assortment_input')
317     ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
318     ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
319     ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
320     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
321     ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
322     ->render;
323 }
324
325 sub action_add_assembly_item {
326   my ($self) = @_;
327
328   validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
329
330   carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
331
332   my $add_item_id = $::form->{add_items}->[0]->{parts_id};
333
334   my $duplicate_warning = 0; # duplicates are allowed, just warn
335   if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
336     $duplicate_warning++;
337   };
338
339   my $number_of_items = scalar @{$self->assembly_items};
340   my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
341   if ($add_item_id ) {
342     foreach my $item (@{$item_objects}) {
343       my $errstr = validate_assembly($item->part,$self->part);
344       return $self->js->flash('error',$errstr)->render if $errstr;
345     }
346   }
347
348
349   my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
350
351   $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
352
353   push(@{$self->assembly_items}, @{$item_objects});
354   my $part = SL::DB::Part->new(part_type => 'assembly');
355   $part->assemblies(@{$self->assembly_items});
356   my $items_sellprice_sum = $part->items_sellprice_sum;
357   my $items_lastcost_sum  = $part->items_lastcost_sum;
358   my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
359
360   $self->js
361     ->append('#assembly_rows', $html)  # append in tbody
362     ->val('.add_assembly_item_input' , '')
363     ->run('kivi.Part.focus_last_assembly_input')
364     ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
365     ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
366     ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
367     ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
368     ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
369     ->render;
370 }
371
372 sub action_show_multi_items_dialog {
373   require SL::DB::PartsGroup;
374   $_[0]->render('part/_multi_items_dialog', { layout => 0 },
375                 part_type => 'assortment',
376                 partfilter => '', # can I get at the current input of the partpicker here?
377                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
378 }
379
380 sub action_multi_items_update_result {
381   my $max_count = 100;
382
383   $::form->{multi_items}->{filter}->{obsolete} = 0;
384
385   my $count = $_[0]->multi_items_models->count;
386
387   if ($count == 0) {
388     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
389     $_[0]->render($text, { layout => 0 });
390   } elsif ($count > $max_count) {
391     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
392     $_[0]->render($text, { layout => 0 });
393   } else {
394     my $multi_items = $_[0]->multi_items_models->get;
395     $_[0]->render('part/_multi_items_result', { layout => 0 },
396                   multi_items => $multi_items);
397   }
398 }
399
400 sub action_add_makemodel_row {
401   my ($self) = @_;
402
403   my $vendor_id = $::form->{add_makemodel};
404
405   my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
406     return $self->js->error(t8("No vendor selected or found!"))->render;
407
408   if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
409     $self->js->flash('info', t8("This vendor has already been added."));
410   };
411
412   my $position = scalar @{$self->makemodels} + 1;
413
414   my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
415                                   make        => $vendor->id,
416                                   model       => '',
417                                   lastcost    => 0,
418                                   sortorder    => $position,
419                                  ) or die "Can't create MakeModel object";
420
421   my $row_as_html = $self->p->render('part/_makemodel_row',
422                                      makemodel => $mm,
423                                      listrow   => $position % 2 ? 0 : 1,
424   );
425
426   # after selection focus on the model field in the row that was just added
427   $self->js
428     ->append('#makemodel_rows', $row_as_html)  # append in tbody
429     ->val('.add_makemodel_input', '')
430     ->run('kivi.Part.focus_last_makemodel_input')
431     ->render;
432 }
433
434 sub action_reorder_items {
435   my ($self) = @_;
436
437   my $part_type = $::form->{part_type};
438
439   my %sort_keys = (
440     partnumber  => sub { $_[0]->part->partnumber },
441     description => sub { $_[0]->part->description },
442     qty         => sub { $_[0]->qty },
443     sellprice   => sub { $_[0]->part->sellprice },
444     lastcost    => sub { $_[0]->part->lastcost },
445     partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
446   );
447
448   my $method = $sort_keys{$::form->{order_by}};
449
450   my @items;
451   if ($part_type eq 'assortment') {
452     @items = @{ $self->assortment_items };
453   } else {
454     @items = @{ $self->assembly_items };
455   };
456
457   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
458   if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
459     if ($::form->{sort_dir}) {
460       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
461     } else {
462       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
463     }
464   } else {
465     if ($::form->{sort_dir}) {
466       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
467     } else {
468       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
469     }
470   };
471
472   $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
473 }
474
475 sub action_warehouse_changed {
476   my ($self) = @_;
477
478   if ($::form->{warehouse_id} ) {
479     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
480     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
481
482     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
483       $self->bin($self->warehouse->bins->[0]);
484       $self->js
485         ->html('#bin', $self->build_bin_select)
486         ->focus('#part_bin_id');
487       return $self->js->render;
488     }
489   }
490
491   # no warehouse was selected, empty the bin field and reset the id
492   $self->js
493        ->val('#part_bin_id', undef)
494        ->html('#bin', '');
495
496   return $self->js->render;
497 }
498
499 sub action_ajax_autocomplete {
500   my ($self, %params) = @_;
501
502   # if someone types something, and hits enter, assume he entered the full name.
503   # if something matches, treat that as sole match
504   # unfortunately get_models can't do more than one per package atm, so we d it
505   # the oldfashioned way.
506   if ($::form->{prefer_exact}) {
507     my $exact_matches;
508     if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
509       query => [
510         obsolete => 0,
511         SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
512         SL::DB::Manager::PartClassification->classification_filter($::form->{filter}{classification_id}),
513         or => [
514           description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
515           partnumber  => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
516         ]
517       ],
518       limit => 2,
519     ) }) {
520       $self->parts($exact_matches);
521     }
522   }
523
524   my @hashes = map {
525    +{
526      value       => $_->displayable_name,
527      label       => $_->displayable_name,
528      id          => $_->id,
529      partnumber  => $_->partnumber,
530      description => $_->description,
531      part_type   => $_->part_type,
532      unit        => $_->unit,
533      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
534     }
535   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
536
537   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
538 }
539
540 sub action_test_page {
541   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
542 }
543
544 sub action_part_picker_search {
545   $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
546 }
547
548 sub action_part_picker_result {
549   $_[0]->render('part/_part_picker_result', { layout => 0 });
550 }
551
552 sub action_show {
553   my ($self) = @_;
554
555   if ($::request->type eq 'json') {
556     my $part_hash;
557     if (!$self->part) {
558       # TODO error
559     } else {
560       $part_hash          = $self->part->as_tree;
561       $part_hash->{cvars} = $self->part->cvar_as_hashref;
562     }
563
564     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
565   }
566 }
567
568 # helper functions
569 sub validate_add_items {
570   scalar @{$::form->{add_items}};
571 }
572
573 sub prepare_assortment_render_vars {
574   my ($self) = @_;
575
576   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
577                items_lastcost_sum  => $self->part->items_lastcost_sum,
578                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
579              );
580   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
581
582   return \%vars;
583 }
584
585 sub prepare_assembly_render_vars {
586   my ($self) = @_;
587
588   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
589                items_lastcost_sum  => $self->part->items_lastcost_sum,
590                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
591              );
592   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
593
594   return \%vars;
595 }
596
597 sub add {
598   my ($self) = @_;
599
600   check_has_valid_part_type($self->part->part_type);
601
602   $self->_set_javascript;
603
604   my %title_hash = ( part       => t8('Add Part'),
605                      assembly   => t8('Add Assembly'),
606                      service    => t8('Add Service'),
607                      assortment => t8('Add Assortment'),
608                    );
609
610   $self->render(
611     'part/form',
612     title             => $title_hash{$self->part->part_type},
613     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
614   );
615 }
616
617
618 sub _set_javascript {
619   my ($self) = @_;
620   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
621   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
622 }
623
624 sub recalc_item_totals {
625   my ($self, %params) = @_;
626
627   if ( $params{part_type} eq 'assortment' ) {
628     return 0 unless scalar @{$self->assortment_items};
629   } elsif ( $params{part_type} eq 'assembly' ) {
630     return 0 unless scalar @{$self->assembly_items};
631   } else {
632     carp "can only calculate sum for assortments and assemblies";
633   };
634
635   my $part = SL::DB::Part->new(part_type => $params{part_type});
636   if ( $part->is_assortment ) {
637     $part->assortment_items( @{$self->assortment_items} );
638     if ( $params{price_type} eq 'lastcost' ) {
639       return $part->items_lastcost_sum;
640     } else {
641       if ( $params{pricegroup_id} ) {
642         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
643       } else {
644         return $part->items_sellprice_sum;
645       };
646     }
647   } elsif ( $part->is_assembly ) {
648     $part->assemblies( @{$self->assembly_items} );
649     if ( $params{price_type} eq 'lastcost' ) {
650       return $part->items_lastcost_sum;
651     } else {
652       return $part->items_sellprice_sum;
653     }
654   }
655 }
656
657 sub check_part_not_modified {
658   my ($self) = @_;
659
660   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
661
662 }
663
664 sub parse_form {
665   my ($self) = @_;
666
667   my $is_new = !$self->part->id;
668
669   my $params = delete($::form->{part}) || { };
670
671   delete $params->{id};
672   # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
673   delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
674   $self->part->assign_attributes(%{ $params});
675   $self->part->bin_id(undef) unless $self->part->warehouse_id;
676
677   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
678   # will be the case for used assortments when saving, or when a used assortment
679   # is "used as new"
680   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
681     $self->part->assortment_items([]);
682     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
683   };
684
685   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
686     $self->part->assemblies([]); # completely rewrite assortments each time
687     $self->part->add_assemblies( @{ $self->assembly_items } );
688   };
689
690   $self->part->translations([]);
691   $self->parse_form_translations;
692
693   $self->part->prices([]);
694   $self->parse_form_prices;
695
696   $self->parse_form_makemodels;
697 }
698
699 sub parse_form_prices {
700   my ($self) = @_;
701   # only save prices > 0
702   my $prices = delete($::form->{prices}) || [];
703   foreach my $price ( @{$prices} ) {
704     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
705     next unless $sellprice > 0; # skip negative prices as well
706     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
707                                pricegroup_id => $price->{pricegroup_id},
708                                price         => $sellprice,
709                               );
710     $self->part->add_prices($p);
711   };
712 }
713
714 sub parse_form_translations {
715   my ($self) = @_;
716   # don't add empty translations
717   my $translations = delete($::form->{translations}) || [];
718   foreach my $translation ( @{$translations} ) {
719     next unless $translation->{translation};
720     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
721     $self->part->add_translations( $translation );
722   };
723 }
724
725 sub parse_form_makemodels {
726   my ($self) = @_;
727
728   my $makemodels_map;
729   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
730     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
731   };
732
733   $self->part->makemodels([]);
734
735   my $position = 0;
736   my $makemodels = delete($::form->{makemodels}) || [];
737   foreach my $makemodel ( @{$makemodels} ) {
738     next unless $makemodel->{make};
739     $position++;
740     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
741
742     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
743                                      id         => $makemodel->{id},
744                                      make       => $makemodel->{make},
745                                      model      => $makemodel->{model} || '',
746                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
747                                      sortorder  => $position,
748                                    );
749     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
750       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
751       # don't change lastupdate
752     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
753       # new makemodel, no lastcost entered, leave lastupdate empty
754     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
755       # lastcost hasn't changed, use original lastupdate
756       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
757     } else {
758       $mm->lastupdate(DateTime->now);
759     };
760     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
761     $self->part->add_makemodels($mm);
762   };
763 }
764
765 sub build_bin_select {
766   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
767     title_key => 'description',
768     default   => $_[0]->bin->id,
769   );
770 }
771
772 # get_set_inits for partpicker
773
774 sub init_parts {
775   if ($::form->{no_paginate}) {
776     $_[0]->models->disable_plugin('paginated');
777   }
778
779   $_[0]->models->get;
780 }
781
782 # get_set_inits for part controller
783 sub init_part {
784   my ($self) = @_;
785
786   # used by edit, save, delete and add
787
788   if ( $::form->{part}{id} ) {
789     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
790   } else {
791     die "part_type missing" unless $::form->{part}{part_type};
792     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
793   };
794 }
795
796 sub init_orphaned {
797   my ($self) = @_;
798   return $self->part->orphaned;
799 }
800
801 sub init_models {
802   my ($self) = @_;
803
804   SL::Controller::Helper::GetModels->new(
805     controller => $self,
806     sorted => {
807       _default  => {
808         by => 'partnumber',
809         dir  => 1,
810       },
811       partnumber  => t8('Partnumber'),
812       description  => t8('Description'),
813     },
814     with_objects => [ qw(unit_obj classification) ],
815   );
816 }
817
818 sub init_p {
819   SL::Presenter->get;
820 }
821
822
823 sub init_assortment_items {
824   # this init is used while saving and whenever assortments change dynamically
825   my ($self) = @_;
826   my $position = 0;
827   my @array;
828   my $assortment_items = delete($::form->{assortment_items}) || [];
829   foreach my $assortment_item ( @{$assortment_items} ) {
830     next unless $assortment_item->{parts_id};
831     $position++;
832     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
833     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
834                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
835                                           charge        => $assortment_item->{charge},
836                                           unit          => $assortment_item->{unit} || $part->unit,
837                                           position      => $position,
838     );
839
840     push(@array, $ai);
841   };
842   return \@array;
843 }
844
845 sub init_makemodels {
846   my ($self) = @_;
847
848   my $position = 0;
849   my @makemodel_array = ();
850   my $makemodels = delete($::form->{makemodels}) || [];
851
852   foreach my $makemodel ( @{$makemodels} ) {
853     next unless $makemodel->{make};
854     $position++;
855     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
856                                     id        => $makemodel->{id},
857                                     make      => $makemodel->{make},
858                                     model     => $makemodel->{model} || '',
859                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
860                                     sortorder => $position,
861                                   ) or die "Can't create mm";
862     # $mm->id($makemodel->{id}) if $makemodel->{id};
863     push(@makemodel_array, $mm);
864   };
865   return \@makemodel_array;
866 }
867
868 sub init_assembly_items {
869   my ($self) = @_;
870   my $position = 0;
871   my @array;
872   my $assembly_items = delete($::form->{assembly_items}) || [];
873   foreach my $assembly_item ( @{$assembly_items} ) {
874     next unless $assembly_item->{parts_id};
875     $position++;
876     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
877     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
878                                    bom         => $assembly_item->{bom},
879                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
880                                    position    => $position,
881                                   );
882     push(@array, $ai);
883   };
884   return \@array;
885 }
886
887 sub init_all_warehouses {
888   my ($self) = @_;
889   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
890 }
891
892 sub init_all_languages {
893   SL::DB::Manager::Language->get_all_sorted;
894 }
895
896 sub init_all_partsgroups {
897   my ($self) = @_;
898   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
899 }
900
901 sub init_all_buchungsgruppen {
902   my ($self) = @_;
903   if ( $self->part->orphaned ) {
904     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
905   } else {
906     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
907   }
908 }
909
910 sub init_all_units {
911   my ($self) = @_;
912   if ( $self->part->orphaned ) {
913     return SL::DB::Manager::Unit->get_all_sorted;
914   } else {
915     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
916   }
917 }
918
919 sub init_all_payment_terms {
920   my ($self) = @_;
921   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
922 }
923
924 sub init_all_price_factors {
925   SL::DB::Manager::PriceFactor->get_all_sorted;
926 }
927
928 sub init_all_pricegroups {
929   SL::DB::Manager::Pricegroup->get_all_sorted;
930 }
931
932 # model used to filter/display the parts in the multi-items dialog
933 sub init_multi_items_models {
934   SL::Controller::Helper::GetModels->new(
935     controller     => $_[0],
936     model          => 'Part',
937     with_objects   => [ qw(unit_obj partsgroup classification) ],
938     disable_plugin => 'paginated',
939     source         => $::form->{multi_items},
940     sorted         => {
941       _default    => {
942         by  => 'partnumber',
943         dir => 1,
944       },
945       partnumber  => t8('Partnumber'),
946       description => t8('Description')}
947   );
948 }
949
950 # simple checks to run on $::form before saving
951
952 sub form_check_part_description_exists {
953   my ($self) = @_;
954
955   return 1 if $::form->{part}{description};
956
957   $self->js->flash('error', t8('Part Description missing!'))
958            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
959            ->focus('#part_description');
960   return 0;
961 }
962
963 sub form_check_assortment_items_exist {
964   my ($self) = @_;
965
966   return 1 unless $::form->{part}{part_type} eq 'assortment';
967   # skip check for existing parts that have been used
968   return 1 if ($self->part->id and !$self->part->orphaned);
969
970   # new or orphaned parts must have items in $::form->{assortment_items}
971   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
972     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
973              ->focus('#add_assortment_item_name')
974              ->flash('error', t8('The assortment doesn\'t have any items.'));
975     return 0;
976   };
977   return 1;
978 }
979
980 sub form_check_assortment_items_unique {
981   my ($self) = @_;
982
983   return 1 unless $::form->{part}{part_type} eq 'assortment';
984
985   my %duplicate_elements;
986   my %count;
987   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
988     $duplicate_elements{$_}++ if $count{$_}++;
989   };
990
991   if ( keys %duplicate_elements ) {
992     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
993              ->flash('error', t8('There are duplicate assortment items'));
994     return 0;
995   };
996   return 1;
997 }
998
999 sub form_check_assembly_items_exist {
1000   my ($self) = @_;
1001
1002   return 1 unless $::form->{part}->{part_type} eq 'assembly';
1003
1004   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1005     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1006              ->focus('#add_assembly_item_name')
1007              ->flash('error', t8('The assembly doesn\'t have any items.'));
1008     return 0;
1009   };
1010   return 1;
1011 }
1012
1013 sub form_check_partnumber_is_unique {
1014   my ($self) = @_;
1015
1016   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1017     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1018     if ( $count ) {
1019       $self->js->flash('error', t8('The partnumber already exists!'))
1020                ->focus('#part_description');
1021       return 0;
1022     };
1023   };
1024   return 1;
1025 }
1026
1027 # general checking functions
1028 sub check_next_transnumber_is_free {
1029   my ($self) = @_;
1030
1031   my ($next_transnumber, $count);
1032   $self->part->db->with_transaction(sub {
1033     $next_transnumber = $self->part->get_next_trans_number;
1034     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1035     return 1;
1036   }) or die $@;
1037   $count ? return 0 : return 1;
1038 }
1039
1040 sub check_part_id {
1041   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1042 }
1043
1044 sub check_form {
1045   my ($self) = @_;
1046
1047   $self->form_check_part_description_exists || return 0;
1048   $self->form_check_assortment_items_exist  || return 0;
1049   $self->form_check_assortment_items_unique || return 0;
1050   $self->form_check_assembly_items_exist    || return 0;
1051   $self->form_check_partnumber_is_unique    || return 0;
1052
1053   return 1;
1054 }
1055
1056 sub check_has_valid_part_type {
1057   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1058 }
1059
1060 sub render_assortment_items_to_html {
1061   my ($self, $assortment_items, $number_of_items) = @_;
1062
1063   my $position = $number_of_items + 1;
1064   my $html;
1065   foreach my $ai (@$assortment_items) {
1066     $html .= $self->p->render('part/_assortment_row',
1067                               PART     => $self->part,
1068                               orphaned => $self->orphaned,
1069                               ITEM     => $ai,
1070                               listrow  => $position % 2 ? 1 : 0,
1071                               position => $position, # for legacy assemblies
1072                              );
1073     $position++;
1074   };
1075   return $html;
1076 }
1077
1078 sub render_assembly_items_to_html {
1079   my ($self, $assembly_items, $number_of_items) = @_;
1080
1081   my $position = $number_of_items + 1;
1082   my $html;
1083   foreach my $ai (@{$assembly_items}) {
1084     $html .= $self->p->render('part/_assembly_row',
1085                               PART     => $self->part,
1086                               orphaned => $self->orphaned,
1087                               ITEM     => $ai,
1088                               listrow  => $position % 2 ? 1 : 0,
1089                               position => $position, # for legacy assemblies
1090                              );
1091     $position++;
1092   };
1093   return $html;
1094 }
1095
1096 sub parse_add_items_to_objects {
1097   my ($self, %params) = @_;
1098   my $part_type = $params{part_type};
1099   die unless $params{part_type} =~ /^(assortment|assembly)$/;
1100   my $position = $params{position} || 1;
1101
1102   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1103
1104   my @item_objects;
1105   foreach my $item ( @add_items ) {
1106     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1107     my $ai;
1108     if ( $part_type eq 'assortment' ) {
1109        $ai = SL::DB::AssortmentItem->new(part          => $part,
1110                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1111                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
1112                                          position      => $position,
1113                                         ) or die "Can't create AssortmentItem from item";
1114     } elsif ( $part_type eq 'assembly' ) {
1115       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
1116                                  # id          => $self->assembly->id, # will be set on save
1117                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1118                                  bom         => 0, # default when adding: no bom
1119                                  position    => $position,
1120                                 );
1121     } else {
1122       die "part_type must be assortment or assembly";
1123     }
1124     push(@item_objects, $ai);
1125     $position++;
1126   };
1127
1128   return \@item_objects;
1129 }
1130
1131 1;
1132
1133 __END__
1134
1135 =encoding utf-8
1136
1137 =head1 NAME
1138
1139 SL::Controller::Part - Part CRUD controller
1140
1141 =head1 DESCRIPTION
1142
1143 Controller for adding/editing/saving/deleting parts.
1144
1145 All the relations are loaded at once and saving the part, adding a history
1146 entry and saving CVars happens inside one transaction.  When saving the old
1147 relations are deleted and written as new to the database.
1148
1149 Relations for parts:
1150
1151 =over 2
1152
1153 =item makemodels
1154
1155 =item translations
1156
1157 =item assembly items
1158
1159 =item assortment items
1160
1161 =item prices
1162
1163 =back
1164
1165 =head1 PART_TYPES
1166
1167 There are 4 different part types:
1168
1169 =over 4
1170
1171 =item C<part>
1172
1173 The "default" part type.
1174
1175 inventory_accno_id is set.
1176
1177 =item C<service>
1178
1179 Services can't be stocked.
1180
1181 inventory_accno_id isn't set.
1182
1183 =item C<assembly>
1184
1185 Assemblies consist of other parts, services, assemblies or assortments. They
1186 aren't meant to be bought, only sold. To add assemblies to stock you typically
1187 have to make them, which reduces the stock by its respective components. Once
1188 an assembly item has been created there is currently no way to "disassemble" it
1189 again. An assembly item can appear several times in one assembly. An assmbly is
1190 sold as one item with a defined sellprice and lastcost. If the component prices
1191 change the assortment price remains the same. The assembly items may be printed
1192 in a record if the item's "bom" is set.
1193
1194 =item C<assortment>
1195
1196 Similar to assembly, but each assortment item may only appear once per
1197 assortment. When selling an assortment the assortment items are added to the
1198 record together with the assortment, which is added with sellprice 0.
1199
1200 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1201 determined by the sum of the current assortment item prices when the assortment
1202 is added to a record. This also means that price rules and customer discounts
1203 will be applied to the assortment items.
1204
1205 Once the assortment items have been added they may be modified or deleted, just
1206 as if they had been added manually, the individual assortment items aren't
1207 linked to the assortment or the other assortment items in any way.
1208
1209 =back
1210
1211 =head1 URL ACTIONS
1212
1213 =over 4
1214
1215 =item C<action_add_part>
1216
1217 =item C<action_add_service>
1218
1219 =item C<action_add_assembly>
1220
1221 =item C<action_add_assortment>
1222
1223 =item C<action_add PART_TYPE>
1224
1225 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1226 parameter part_type as an action. Example:
1227
1228   controller.pl?action=Part/add&part_type=service
1229
1230 =item C<action_save>
1231
1232 Saves the current part and then reloads the edit page for the part.
1233
1234 =item C<action_use_as_new>
1235
1236 Takes the information from the current part, plus any modifications made on the
1237 page, and creates a new edit page that is ready to be saved. The partnumber is
1238 set empty, so a new partnumber from the number range will be used if the user
1239 doesn't enter one manually.
1240
1241 Unsaved changes to the original part aren't updated.
1242
1243 The part type cannot be changed in this way.
1244
1245 =item C<action_delete>
1246
1247 Deletes the current part and then redirects to the main page, there is no
1248 callback.
1249
1250 The delete button only appears if the part is 'orphaned', according to
1251 SL::DB::Part orphaned.
1252
1253 The part can't be deleted if it appears in invoices, orders, delivery orders,
1254 the inventory, or is part of an assembly or assortment.
1255
1256 If the part is deleted its relations prices, makdemodel, assembly,
1257 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1258
1259 Before this controller items that appeared in inventory didn't count as
1260 orphaned and could be deleted and the inventory entries were also deleted, this
1261 "feature" hasn't been implemented.
1262
1263 =item C<action_edit part.id>
1264
1265 Load and display a part for editing.
1266
1267   controller.pl?action=Part/edit&part.id=12345
1268
1269 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1270
1271 =back
1272
1273 =head1 BUTTON ACTIONS
1274
1275 =over 4
1276
1277 =item C<history>
1278
1279 Opens a popup displaying all the history entries. Once a new history controller
1280 is written the button could link there instead, with the part already selected.
1281
1282 =back
1283
1284 =head1 AJAX ACTIONS
1285
1286 =over 4
1287
1288 =item C<action_update_item_totals>
1289
1290 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1291 amount of an item changes. The sum of all sellprices and lastcosts is
1292 calculated and the totals updated. Uses C<recalc_item_totals>.
1293
1294 =item C<action_add_assortment_item>
1295
1296 Adds a new assortment item from a part picker seleciton to the assortment item list
1297
1298 If the item already exists in the assortment the item isn't added and a Flash
1299 error shown.
1300
1301 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1302 after adding each new item, add the new object to the item objects that were
1303 already parsed, calculate totals via a dummy part then update the row and the
1304 totals.
1305
1306 =item C<action_add_assembly_item>
1307
1308 Adds a new assembly item from a part picker seleciton to the assembly item list
1309
1310 If the item already exists in the assembly a flash info is generated, but the
1311 item is added.
1312
1313 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1314 after adding each new item, add the new object to the item objects that were
1315 already parsed, calculate totals via a dummy part then update the row and the
1316 totals.
1317
1318 =item C<action_add_multi_assortment_items>
1319
1320 Parses the items to be added from the form generated by the multi input and
1321 appends the html of the tr-rows to the assortment item table. Afterwards all
1322 assortment items are renumbered and the sums recalculated via
1323 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1324
1325 =item C<action_add_multi_assembly_items>
1326
1327 Parses the items to be added from the form generated by the multi input and
1328 appends the html of the tr-rows to the assembly item table. Afterwards all
1329 assembly items are renumbered and the sums recalculated via
1330 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1331
1332 =item C<action_show_multi_items_dialog>
1333
1334 =item C<action_multi_items_update_result>
1335
1336 =item C<action_add_makemodel_row>
1337
1338 Add a new makemodel row with the vendor that was selected via the vendor
1339 picker.
1340
1341 Checks the already existing makemodels and warns if a row with that vendor
1342 already exists. Currently it is possible to have duplicate vendor rows.
1343
1344 =item C<action_reorder_items>
1345
1346 Sorts the item table for assembly or assortment items.
1347
1348 =item C<action_warehouse_changed>
1349
1350 =back
1351
1352 =head1 ACTIONS part picker
1353
1354 =over 4
1355
1356 =item C<action_ajax_autocomplete>
1357
1358 =item C<action_test_page>
1359
1360 =item C<action_part_picker_search>
1361
1362 =item C<action_part_picker_result>
1363
1364 =item C<action_show>
1365
1366 =back
1367
1368 =head1 FORM CHECKS
1369
1370 =over 2
1371
1372 =item C<check_form>
1373
1374 Calls some simple checks that test the submitted $::form for obvious errors.
1375 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1376
1377 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1378 some cases extra actions are taken, e.g. if the part description is missing the
1379 basic data tab is selected and the description input field is focussed.
1380
1381 =back
1382
1383 =over 4
1384
1385 =item C<form_check_part_description_exists>
1386
1387 =item C<form_check_assortment_items_exist>
1388
1389 =item C<form_check_assortment_items_unique>
1390
1391 =item C<form_check_assembly_items_exist>
1392
1393 =item C<form_check_partnumber_is_unique>
1394
1395 =back
1396
1397 =head1 HELPER FUNCTIONS
1398
1399 =over 4
1400
1401 =item C<parse_form>
1402
1403 When submitting the form for saving, parses the transmitted form. Expects the
1404 following data:
1405
1406  $::form->{part}
1407  $::form->{makemodels}
1408  $::form->{translations}
1409  $::form->{prices}
1410  $::form->{assemblies}
1411  $::form->{assortments}
1412
1413 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1414
1415 =item C<recalc_item_totals %params>
1416
1417 Helper function for calculating the total lastcost and sellprice for assemblies
1418 or assortments according to their items, which are parsed from the current
1419 $::form.
1420
1421 Is called whenever the qty of an item is changed or items are deleted.
1422
1423 Takes two params:
1424
1425 * part_type : 'assortment' or 'assembly' (mandatory)
1426
1427 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1428
1429 Depending on the price_type the lastcost sum or sellprice sum is returned.
1430
1431 Doesn't work for recursive items.
1432
1433 =back
1434
1435 =head1 GET SET INITS
1436
1437 There are get_set_inits for
1438
1439 * assembly items
1440
1441 * assortment items
1442
1443 * makemodels
1444
1445 which parse $::form and automatically create an array of objects.
1446
1447 These inits are used during saving and each time a new element is added.
1448
1449 =over 4
1450
1451 =item C<init_makemodels>
1452
1453 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1454 $self->part->makemodels, ready to be saved.
1455
1456 Used for saving parts and adding new makemodel rows.
1457
1458 =item C<parse_add_items_to_objects PART_TYPE>
1459
1460 Parses the resulting form from either the part-picker submit or the multi-item
1461 submit, and creates an arrayref of assortment_item or assembly objects, that
1462 can be rendered via C<render_assortment_items_to_html> or
1463 C<render_assembly_items_to_html>.
1464
1465 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1466 Optional param: position (used for numbering and listrow class)
1467
1468 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1469
1470 Takes an array_ref of assortment_items, and generates tables rows ready for
1471 adding to the assortment table.  Is used when a part is loaded, or whenever new
1472 assortment items are added.
1473
1474 =item C<parse_form_makemodels>
1475
1476 Makemodels can't just be overwritten, because of the field "lastupdate", that
1477 remembers when the lastcost for that vendor changed the last time.
1478
1479 So the original values are cloned and remembered, so we can compare if lastcost
1480 was changed in $::form, and keep or update lastupdate.
1481
1482 lastcost isn't updated until the first time it was saved with a value, until
1483 then it is empty.
1484
1485 Also a boolean "makemodel" needs to be written in parts, depending on whether
1486 makemodel entries exist or not.
1487
1488 We still need init_makemodels for when we open the part for editing.
1489
1490 =back
1491
1492 =head1 TODO
1493
1494 =over 4
1495
1496 =item *
1497
1498 It should be possible to jump to the edit page in a specific tab
1499
1500 =item *
1501
1502 Support callbacks, e.g. creating a new part from within an order, and jumping
1503 back to the order again afterwards.
1504
1505 =item *
1506
1507 Support units when adding assembly items or assortment items. Currently the
1508 default unit of the item is always used.
1509
1510 =item *
1511
1512 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1513 consists of other assemblies.
1514
1515 =back
1516
1517 =head1 AUTHOR
1518
1519 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
1520
1521 =cut