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