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