ActionBar: Standardaktion im Artikelstammdatencontroller auf »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   $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   $self->_setup_form_action_bar;
198
199   my (%assortment_vars, %assembly_vars);
200   %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
201   %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
202
203   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
204
205   CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
206     if (scalar @{ $params{CUSTOM_VARIABLES} });
207
208   my %title_hash = ( part       => t8('Edit Part'),
209                      assembly   => t8('Edit Assembly'),
210                      service    => t8('Edit Service'),
211                      assortment => t8('Edit Assortment'),
212                    );
213
214   $self->part->prices([])       unless $self->part->prices;
215   $self->part->translations([]) unless $self->part->translations;
216
217   $self->render(
218     'part/form',
219     title             => $title_hash{$self->part->part_type},
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   # since we need a second get models instance with different filters for that,
504   # we only modify the original filter temporarily in place
505   if ($::form->{prefer_exact}) {
506     local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
507
508     my $exact_models = SL::Controller::Helper::GetModels->new(
509       controller   => $self,
510       sorted       => 0,
511       paginated    => { per_page => 2 },
512       with_objects => [ qw(unit_obj classification) ],
513     );
514     my $exact_matches;
515     if (1 == scalar @{ $exact_matches = $exact_models->get }) {
516       $self->parts($exact_matches);
517     }
518   }
519
520   my @hashes = map {
521    +{
522      value       => $_->displayable_name,
523      label       => $_->displayable_name,
524      id          => $_->id,
525      partnumber  => $_->partnumber,
526      description => $_->description,
527      part_type   => $_->part_type,
528      unit        => $_->unit,
529      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
530     }
531   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
532
533   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
534 }
535
536 sub action_test_page {
537   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
538 }
539
540 sub action_part_picker_search {
541   $_[0]->render('part/part_picker_search', { layout => 0 });
542 }
543
544 sub action_part_picker_result {
545   $_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
546 }
547
548 sub action_show {
549   my ($self) = @_;
550
551   if ($::request->type eq 'json') {
552     my $part_hash;
553     if (!$self->part) {
554       # TODO error
555     } else {
556       $part_hash          = $self->part->as_tree;
557       $part_hash->{cvars} = $self->part->cvar_as_hashref;
558     }
559
560     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
561   }
562 }
563
564 # helper functions
565 sub validate_add_items {
566   scalar @{$::form->{add_items}};
567 }
568
569 sub prepare_assortment_render_vars {
570   my ($self) = @_;
571
572   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
573                items_lastcost_sum  => $self->part->items_lastcost_sum,
574                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
575              );
576   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
577
578   return \%vars;
579 }
580
581 sub prepare_assembly_render_vars {
582   my ($self) = @_;
583
584   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
585                items_lastcost_sum  => $self->part->items_lastcost_sum,
586                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
587              );
588   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
589
590   return \%vars;
591 }
592
593 sub add {
594   my ($self) = @_;
595
596   check_has_valid_part_type($self->part->part_type);
597
598   $self->_set_javascript;
599   $self->_setup_form_action_bar;
600
601   my %title_hash = ( part       => t8('Add Part'),
602                      assembly   => t8('Add Assembly'),
603                      service    => t8('Add Service'),
604                      assortment => t8('Add Assortment'),
605                    );
606
607   $self->render(
608     'part/form',
609     title => $title_hash{$self->part->part_type},
610   );
611 }
612
613
614 sub _set_javascript {
615   my ($self) = @_;
616   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
617   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
618 }
619
620 sub recalc_item_totals {
621   my ($self, %params) = @_;
622
623   if ( $params{part_type} eq 'assortment' ) {
624     return 0 unless scalar @{$self->assortment_items};
625   } elsif ( $params{part_type} eq 'assembly' ) {
626     return 0 unless scalar @{$self->assembly_items};
627   } else {
628     carp "can only calculate sum for assortments and assemblies";
629   };
630
631   my $part = SL::DB::Part->new(part_type => $params{part_type});
632   if ( $part->is_assortment ) {
633     $part->assortment_items( @{$self->assortment_items} );
634     if ( $params{price_type} eq 'lastcost' ) {
635       return $part->items_lastcost_sum;
636     } else {
637       if ( $params{pricegroup_id} ) {
638         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
639       } else {
640         return $part->items_sellprice_sum;
641       };
642     }
643   } elsif ( $part->is_assembly ) {
644     $part->assemblies( @{$self->assembly_items} );
645     if ( $params{price_type} eq 'lastcost' ) {
646       return $part->items_lastcost_sum;
647     } else {
648       return $part->items_sellprice_sum;
649     }
650   }
651 }
652
653 sub check_part_not_modified {
654   my ($self) = @_;
655
656   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
657
658 }
659
660 sub parse_form {
661   my ($self) = @_;
662
663   my $is_new = !$self->part->id;
664
665   my $params = delete($::form->{part}) || { };
666
667   delete $params->{id};
668   # never overwrite existing partnumber for parts in use, should be a read-only field in that case anyway
669   delete $params->{partnumber} if $self->part->partnumber and not $self->orphaned;
670   $self->part->assign_attributes(%{ $params});
671   $self->part->bin_id(undef) unless $self->part->warehouse_id;
672
673   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
674   # will be the case for used assortments when saving, or when a used assortment
675   # is "used as new"
676   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
677     $self->part->assortment_items([]);
678     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
679   };
680
681   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
682     $self->part->assemblies([]); # completely rewrite assortments each time
683     $self->part->add_assemblies( @{ $self->assembly_items } );
684   };
685
686   $self->part->translations([]);
687   $self->parse_form_translations;
688
689   $self->part->prices([]);
690   $self->parse_form_prices;
691
692   $self->parse_form_makemodels;
693 }
694
695 sub parse_form_prices {
696   my ($self) = @_;
697   # only save prices > 0
698   my $prices = delete($::form->{prices}) || [];
699   foreach my $price ( @{$prices} ) {
700     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
701     next unless $sellprice > 0; # skip negative prices as well
702     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
703                                pricegroup_id => $price->{pricegroup_id},
704                                price         => $sellprice,
705                               );
706     $self->part->add_prices($p);
707   };
708 }
709
710 sub parse_form_translations {
711   my ($self) = @_;
712   # don't add empty translations
713   my $translations = delete($::form->{translations}) || [];
714   foreach my $translation ( @{$translations} ) {
715     next unless $translation->{translation};
716     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
717     $self->part->add_translations( $translation );
718   };
719 }
720
721 sub parse_form_makemodels {
722   my ($self) = @_;
723
724   my $makemodels_map;
725   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
726     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
727   };
728
729   $self->part->makemodels([]);
730
731   my $position = 0;
732   my $makemodels = delete($::form->{makemodels}) || [];
733   foreach my $makemodel ( @{$makemodels} ) {
734     next unless $makemodel->{make};
735     $position++;
736     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
737
738     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
739                                      id         => $makemodel->{id},
740                                      make       => $makemodel->{make},
741                                      model      => $makemodel->{model} || '',
742                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
743                                      sortorder  => $position,
744                                    );
745     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
746       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
747       # don't change lastupdate
748     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
749       # new makemodel, no lastcost entered, leave lastupdate empty
750     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
751       # lastcost hasn't changed, use original lastupdate
752       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
753     } else {
754       $mm->lastupdate(DateTime->now);
755     };
756     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
757     $self->part->add_makemodels($mm);
758   };
759 }
760
761 sub build_bin_select {
762   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
763     title_key => 'description',
764     default   => $_[0]->bin->id,
765   );
766 }
767
768 # get_set_inits for partpicker
769
770 sub init_parts {
771   if ($::form->{no_paginate}) {
772     $_[0]->models->disable_plugin('paginated');
773   }
774
775   $_[0]->models->get;
776 }
777
778 # get_set_inits for part controller
779 sub init_part {
780   my ($self) = @_;
781
782   # used by edit, save, delete and add
783
784   if ( $::form->{part}{id} ) {
785     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
786   } else {
787     die "part_type missing" unless $::form->{part}{part_type};
788     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
789   };
790 }
791
792 sub init_orphaned {
793   my ($self) = @_;
794   return $self->part->orphaned;
795 }
796
797 sub init_models {
798   my ($self) = @_;
799
800   SL::Controller::Helper::GetModels->new(
801     controller => $self,
802     sorted => {
803       _default  => {
804         by => 'partnumber',
805         dir  => 1,
806       },
807       partnumber  => t8('Partnumber'),
808       description  => t8('Description'),
809     },
810     with_objects => [ qw(unit_obj classification) ],
811   );
812 }
813
814 sub init_p {
815   SL::Presenter->get;
816 }
817
818
819 sub init_assortment_items {
820   # this init is used while saving and whenever assortments change dynamically
821   my ($self) = @_;
822   my $position = 0;
823   my @array;
824   my $assortment_items = delete($::form->{assortment_items}) || [];
825   foreach my $assortment_item ( @{$assortment_items} ) {
826     next unless $assortment_item->{parts_id};
827     $position++;
828     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
829     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
830                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
831                                           charge        => $assortment_item->{charge},
832                                           unit          => $assortment_item->{unit} || $part->unit,
833                                           position      => $position,
834     );
835
836     push(@array, $ai);
837   };
838   return \@array;
839 }
840
841 sub init_makemodels {
842   my ($self) = @_;
843
844   my $position = 0;
845   my @makemodel_array = ();
846   my $makemodels = delete($::form->{makemodels}) || [];
847
848   foreach my $makemodel ( @{$makemodels} ) {
849     next unless $makemodel->{make};
850     $position++;
851     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
852                                     id        => $makemodel->{id},
853                                     make      => $makemodel->{make},
854                                     model     => $makemodel->{model} || '',
855                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
856                                     sortorder => $position,
857                                   ) or die "Can't create mm";
858     # $mm->id($makemodel->{id}) if $makemodel->{id};
859     push(@makemodel_array, $mm);
860   };
861   return \@makemodel_array;
862 }
863
864 sub init_assembly_items {
865   my ($self) = @_;
866   my $position = 0;
867   my @array;
868   my $assembly_items = delete($::form->{assembly_items}) || [];
869   foreach my $assembly_item ( @{$assembly_items} ) {
870     next unless $assembly_item->{parts_id};
871     $position++;
872     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
873     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
874                                    bom         => $assembly_item->{bom},
875                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
876                                    position    => $position,
877                                   );
878     push(@array, $ai);
879   };
880   return \@array;
881 }
882
883 sub init_all_warehouses {
884   my ($self) = @_;
885   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
886 }
887
888 sub init_all_languages {
889   SL::DB::Manager::Language->get_all_sorted;
890 }
891
892 sub init_all_partsgroups {
893   my ($self) = @_;
894   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
895 }
896
897 sub init_all_buchungsgruppen {
898   my ($self) = @_;
899   if ( $self->part->orphaned ) {
900     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
901   } else {
902     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
903   }
904 }
905
906 sub init_all_units {
907   my ($self) = @_;
908   if ( $self->part->orphaned ) {
909     return SL::DB::Manager::Unit->get_all_sorted;
910   } else {
911     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
912   }
913 }
914
915 sub init_all_payment_terms {
916   my ($self) = @_;
917   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
918 }
919
920 sub init_all_price_factors {
921   SL::DB::Manager::PriceFactor->get_all_sorted;
922 }
923
924 sub init_all_pricegroups {
925   SL::DB::Manager::Pricegroup->get_all_sorted;
926 }
927
928 # model used to filter/display the parts in the multi-items dialog
929 sub init_multi_items_models {
930   SL::Controller::Helper::GetModels->new(
931     controller     => $_[0],
932     model          => 'Part',
933     with_objects   => [ qw(unit_obj partsgroup classification) ],
934     disable_plugin => 'paginated',
935     source         => $::form->{multi_items},
936     sorted         => {
937       _default    => {
938         by  => 'partnumber',
939         dir => 1,
940       },
941       partnumber  => t8('Partnumber'),
942       description => t8('Description')}
943   );
944 }
945
946 # simple checks to run on $::form before saving
947
948 sub form_check_part_description_exists {
949   my ($self) = @_;
950
951   return 1 if $::form->{part}{description};
952
953   $self->js->flash('error', t8('Part Description missing!'))
954            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
955            ->focus('#part_description');
956   return 0;
957 }
958
959 sub form_check_assortment_items_exist {
960   my ($self) = @_;
961
962   return 1 unless $::form->{part}{part_type} eq 'assortment';
963   # skip item check for existing assortments that have been used
964   return 1 if ($self->part->id and !$self->part->orphaned);
965
966   # new or orphaned parts must have items in $::form->{assortment_items}
967   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
968     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
969              ->focus('#add_assortment_item_name')
970              ->flash('error', t8('The assortment doesn\'t have any items.'));
971     return 0;
972   };
973   return 1;
974 }
975
976 sub form_check_assortment_items_unique {
977   my ($self) = @_;
978
979   return 1 unless $::form->{part}{part_type} eq 'assortment';
980
981   my %duplicate_elements;
982   my %count;
983   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
984     $duplicate_elements{$_}++ if $count{$_}++;
985   };
986
987   if ( keys %duplicate_elements ) {
988     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
989              ->flash('error', t8('There are duplicate assortment items'));
990     return 0;
991   };
992   return 1;
993 }
994
995 sub form_check_assembly_items_exist {
996   my ($self) = @_;
997
998   return 1 unless $::form->{part}->{part_type} eq 'assembly';
999
1000   # skip item check for existing assembly that have been used
1001   return 1 if ($self->part->id and !$self->part->orphaned);
1002
1003   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
1004     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
1005              ->focus('#add_assembly_item_name')
1006              ->flash('error', t8('The assembly doesn\'t have any items.'));
1007     return 0;
1008   };
1009   return 1;
1010 }
1011
1012 sub form_check_partnumber_is_unique {
1013   my ($self) = @_;
1014
1015   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
1016     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
1017     if ( $count ) {
1018       $self->js->flash('error', t8('The partnumber already exists!'))
1019                ->focus('#part_description');
1020       return 0;
1021     };
1022   };
1023   return 1;
1024 }
1025
1026 # general checking functions
1027 sub check_next_transnumber_is_free {
1028   my ($self) = @_;
1029
1030   my ($next_transnumber, $count);
1031   $self->part->db->with_transaction(sub {
1032     $next_transnumber = $self->part->get_next_trans_number;
1033     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1034     return 1;
1035   }) or die $@;
1036   $count ? return 0 : return 1;
1037 }
1038
1039 sub check_part_id {
1040   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1041 }
1042
1043 sub check_form {
1044   my ($self) = @_;
1045
1046   $self->form_check_part_description_exists || return 0;
1047   $self->form_check_assortment_items_exist  || return 0;
1048   $self->form_check_assortment_items_unique || return 0;
1049   $self->form_check_assembly_items_exist    || return 0;
1050   $self->form_check_partnumber_is_unique    || return 0;
1051
1052   return 1;
1053 }
1054
1055 sub check_has_valid_part_type {
1056   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1057 }
1058
1059 sub render_assortment_items_to_html {
1060   my ($self, $assortment_items, $number_of_items) = @_;
1061
1062   my $position = $number_of_items + 1;
1063   my $html;
1064   foreach my $ai (@$assortment_items) {
1065     $html .= $self->p->render('part/_assortment_row',
1066                               PART     => $self->part,
1067                               orphaned => $self->orphaned,
1068                               ITEM     => $ai,
1069                               listrow  => $position % 2 ? 1 : 0,
1070                               position => $position, # for legacy assemblies
1071                              );
1072     $position++;
1073   };
1074   return $html;
1075 }
1076
1077 sub render_assembly_items_to_html {
1078   my ($self, $assembly_items, $number_of_items) = @_;
1079
1080   my $position = $number_of_items + 1;
1081   my $html;
1082   foreach my $ai (@{$assembly_items}) {
1083     $html .= $self->p->render('part/_assembly_row',
1084                               PART     => $self->part,
1085                               orphaned => $self->orphaned,
1086                               ITEM     => $ai,
1087                               listrow  => $position % 2 ? 1 : 0,
1088                               position => $position, # for legacy assemblies
1089                              );
1090     $position++;
1091   };
1092   return $html;
1093 }
1094
1095 sub parse_add_items_to_objects {
1096   my ($self, %params) = @_;
1097   my $part_type = $params{part_type};
1098   die unless $params{part_type} =~ /^(assortment|assembly)$/;
1099   my $position = $params{position} || 1;
1100
1101   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1102
1103   my @item_objects;
1104   foreach my $item ( @add_items ) {
1105     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1106     my $ai;
1107     if ( $part_type eq 'assortment' ) {
1108        $ai = SL::DB::AssortmentItem->new(part          => $part,
1109                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1110                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
1111                                          position      => $position,
1112                                         ) or die "Can't create AssortmentItem from item";
1113     } elsif ( $part_type eq 'assembly' ) {
1114       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
1115                                  # id          => $self->assembly->id, # will be set on save
1116                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1117                                  bom         => 0, # default when adding: no bom
1118                                  position    => $position,
1119                                 );
1120     } else {
1121       die "part_type must be assortment or assembly";
1122     }
1123     push(@item_objects, $ai);
1124     $position++;
1125   };
1126
1127   return \@item_objects;
1128 }
1129
1130 sub _setup_form_action_bar {
1131   my ($self) = @_;
1132
1133   my $may_edit = $::auth->assert('part_service_assembly_edit', 'may fail');
1134
1135   for my $bar ($::request->layout->get('actionbar')) {
1136     $bar->add(
1137       combobox => [
1138         action => [
1139           t8('Save'),
1140           call      => [ 'kivi.Part.save' ],
1141           disabled  => !$may_edit ? t8('You do not have the permissions to access this function.') : undef,
1142           accesskey => 'enter',
1143         ],
1144         action => [
1145           t8('Use as new'),
1146           call     => [ 'kivi.Part.use_as_new' ],
1147           disabled => !$self->part->id ? t8('The object has not been saved yet.')
1148                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
1149                     :                    undef,
1150         ],
1151       ], # end of combobox "Save"
1152
1153       action => [
1154         t8('Delete'),
1155         call     => [ 'kivi.Part.delete' ],
1156         confirm  => t8('Do you really want to delete this object?'),
1157         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
1158                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
1159                   : !$self->part->orphaned ? t8('This object has already been used.')
1160                   :                          undef,
1161       ],
1162
1163       'separator',
1164
1165       action => [
1166         t8('History'),
1167         call     => [ 'kivi.Part.open_history_popup' ],
1168         disabled => !$self->part->id ? t8('This object has not been saved yet.')
1169                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
1170                   :                    undef,
1171       ],
1172     );
1173   }
1174 }
1175
1176 1;
1177
1178 __END__
1179
1180 =encoding utf-8
1181
1182 =head1 NAME
1183
1184 SL::Controller::Part - Part CRUD controller
1185
1186 =head1 DESCRIPTION
1187
1188 Controller for adding/editing/saving/deleting parts.
1189
1190 All the relations are loaded at once and saving the part, adding a history
1191 entry and saving CVars happens inside one transaction.  When saving the old
1192 relations are deleted and written as new to the database.
1193
1194 Relations for parts:
1195
1196 =over 2
1197
1198 =item makemodels
1199
1200 =item translations
1201
1202 =item assembly items
1203
1204 =item assortment items
1205
1206 =item prices
1207
1208 =back
1209
1210 =head1 PART_TYPES
1211
1212 There are 4 different part types:
1213
1214 =over 4
1215
1216 =item C<part>
1217
1218 The "default" part type.
1219
1220 inventory_accno_id is set.
1221
1222 =item C<service>
1223
1224 Services can't be stocked.
1225
1226 inventory_accno_id isn't set.
1227
1228 =item C<assembly>
1229
1230 Assemblies consist of other parts, services, assemblies or assortments. They
1231 aren't meant to be bought, only sold. To add assemblies to stock you typically
1232 have to make them, which reduces the stock by its respective components. Once
1233 an assembly item has been created there is currently no way to "disassemble" it
1234 again. An assembly item can appear several times in one assembly. An assmbly is
1235 sold as one item with a defined sellprice and lastcost. If the component prices
1236 change the assortment price remains the same. The assembly items may be printed
1237 in a record if the item's "bom" is set.
1238
1239 =item C<assortment>
1240
1241 Similar to assembly, but each assortment item may only appear once per
1242 assortment. When selling an assortment the assortment items are added to the
1243 record together with the assortment, which is added with sellprice 0.
1244
1245 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1246 determined by the sum of the current assortment item prices when the assortment
1247 is added to a record. This also means that price rules and customer discounts
1248 will be applied to the assortment items.
1249
1250 Once the assortment items have been added they may be modified or deleted, just
1251 as if they had been added manually, the individual assortment items aren't
1252 linked to the assortment or the other assortment items in any way.
1253
1254 =back
1255
1256 =head1 URL ACTIONS
1257
1258 =over 4
1259
1260 =item C<action_add_part>
1261
1262 =item C<action_add_service>
1263
1264 =item C<action_add_assembly>
1265
1266 =item C<action_add_assortment>
1267
1268 =item C<action_add PART_TYPE>
1269
1270 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1271 parameter part_type as an action. Example:
1272
1273   controller.pl?action=Part/add&part_type=service
1274
1275 =item C<action_save>
1276
1277 Saves the current part and then reloads the edit page for the part.
1278
1279 =item C<action_use_as_new>
1280
1281 Takes the information from the current part, plus any modifications made on the
1282 page, and creates a new edit page that is ready to be saved. The partnumber is
1283 set empty, so a new partnumber from the number range will be used if the user
1284 doesn't enter one manually.
1285
1286 Unsaved changes to the original part aren't updated.
1287
1288 The part type cannot be changed in this way.
1289
1290 =item C<action_delete>
1291
1292 Deletes the current part and then redirects to the main page, there is no
1293 callback.
1294
1295 The delete button only appears if the part is 'orphaned', according to
1296 SL::DB::Part orphaned.
1297
1298 The part can't be deleted if it appears in invoices, orders, delivery orders,
1299 the inventory, or is part of an assembly or assortment.
1300
1301 If the part is deleted its relations prices, makdemodel, assembly,
1302 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1303
1304 Before this controller items that appeared in inventory didn't count as
1305 orphaned and could be deleted and the inventory entries were also deleted, this
1306 "feature" hasn't been implemented.
1307
1308 =item C<action_edit part.id>
1309
1310 Load and display a part for editing.
1311
1312   controller.pl?action=Part/edit&part.id=12345
1313
1314 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1315
1316 =back
1317
1318 =head1 BUTTON ACTIONS
1319
1320 =over 4
1321
1322 =item C<history>
1323
1324 Opens a popup displaying all the history entries. Once a new history controller
1325 is written the button could link there instead, with the part already selected.
1326
1327 =back
1328
1329 =head1 AJAX ACTIONS
1330
1331 =over 4
1332
1333 =item C<action_update_item_totals>
1334
1335 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1336 amount of an item changes. The sum of all sellprices and lastcosts is
1337 calculated and the totals updated. Uses C<recalc_item_totals>.
1338
1339 =item C<action_add_assortment_item>
1340
1341 Adds a new assortment item from a part picker seleciton to the assortment item list
1342
1343 If the item already exists in the assortment the item isn't added and a Flash
1344 error shown.
1345
1346 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1347 after adding each new item, add the new object to the item objects that were
1348 already parsed, calculate totals via a dummy part then update the row and the
1349 totals.
1350
1351 =item C<action_add_assembly_item>
1352
1353 Adds a new assembly item from a part picker seleciton to the assembly item list
1354
1355 If the item already exists in the assembly a flash info is generated, but the
1356 item is added.
1357
1358 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1359 after adding each new item, add the new object to the item objects that were
1360 already parsed, calculate totals via a dummy part then update the row and the
1361 totals.
1362
1363 =item C<action_add_multi_assortment_items>
1364
1365 Parses the items to be added from the form generated by the multi input and
1366 appends the html of the tr-rows to the assortment item table. Afterwards all
1367 assortment items are renumbered and the sums recalculated via
1368 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1369
1370 =item C<action_add_multi_assembly_items>
1371
1372 Parses the items to be added from the form generated by the multi input and
1373 appends the html of the tr-rows to the assembly item table. Afterwards all
1374 assembly items are renumbered and the sums recalculated via
1375 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1376
1377 =item C<action_show_multi_items_dialog>
1378
1379 =item C<action_multi_items_update_result>
1380
1381 =item C<action_add_makemodel_row>
1382
1383 Add a new makemodel row with the vendor that was selected via the vendor
1384 picker.
1385
1386 Checks the already existing makemodels and warns if a row with that vendor
1387 already exists. Currently it is possible to have duplicate vendor rows.
1388
1389 =item C<action_reorder_items>
1390
1391 Sorts the item table for assembly or assortment items.
1392
1393 =item C<action_warehouse_changed>
1394
1395 =back
1396
1397 =head1 ACTIONS part picker
1398
1399 =over 4
1400
1401 =item C<action_ajax_autocomplete>
1402
1403 =item C<action_test_page>
1404
1405 =item C<action_part_picker_search>
1406
1407 =item C<action_part_picker_result>
1408
1409 =item C<action_show>
1410
1411 =back
1412
1413 =head1 FORM CHECKS
1414
1415 =over 2
1416
1417 =item C<check_form>
1418
1419 Calls some simple checks that test the submitted $::form for obvious errors.
1420 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1421
1422 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1423 some cases extra actions are taken, e.g. if the part description is missing the
1424 basic data tab is selected and the description input field is focussed.
1425
1426 =back
1427
1428 =over 4
1429
1430 =item C<form_check_part_description_exists>
1431
1432 =item C<form_check_assortment_items_exist>
1433
1434 =item C<form_check_assortment_items_unique>
1435
1436 =item C<form_check_assembly_items_exist>
1437
1438 =item C<form_check_partnumber_is_unique>
1439
1440 =back
1441
1442 =head1 HELPER FUNCTIONS
1443
1444 =over 4
1445
1446 =item C<parse_form>
1447
1448 When submitting the form for saving, parses the transmitted form. Expects the
1449 following data:
1450
1451  $::form->{part}
1452  $::form->{makemodels}
1453  $::form->{translations}
1454  $::form->{prices}
1455  $::form->{assemblies}
1456  $::form->{assortments}
1457
1458 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1459
1460 =item C<recalc_item_totals %params>
1461
1462 Helper function for calculating the total lastcost and sellprice for assemblies
1463 or assortments according to their items, which are parsed from the current
1464 $::form.
1465
1466 Is called whenever the qty of an item is changed or items are deleted.
1467
1468 Takes two params:
1469
1470 * part_type : 'assortment' or 'assembly' (mandatory)
1471
1472 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1473
1474 Depending on the price_type the lastcost sum or sellprice sum is returned.
1475
1476 Doesn't work for recursive items.
1477
1478 =back
1479
1480 =head1 GET SET INITS
1481
1482 There are get_set_inits for
1483
1484 * assembly items
1485
1486 * assortment items
1487
1488 * makemodels
1489
1490 which parse $::form and automatically create an array of objects.
1491
1492 These inits are used during saving and each time a new element is added.
1493
1494 =over 4
1495
1496 =item C<init_makemodels>
1497
1498 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1499 $self->part->makemodels, ready to be saved.
1500
1501 Used for saving parts and adding new makemodel rows.
1502
1503 =item C<parse_add_items_to_objects PART_TYPE>
1504
1505 Parses the resulting form from either the part-picker submit or the multi-item
1506 submit, and creates an arrayref of assortment_item or assembly objects, that
1507 can be rendered via C<render_assortment_items_to_html> or
1508 C<render_assembly_items_to_html>.
1509
1510 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1511 Optional param: position (used for numbering and listrow class)
1512
1513 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1514
1515 Takes an array_ref of assortment_items, and generates tables rows ready for
1516 adding to the assortment table.  Is used when a part is loaded, or whenever new
1517 assortment items are added.
1518
1519 =item C<parse_form_makemodels>
1520
1521 Makemodels can't just be overwritten, because of the field "lastupdate", that
1522 remembers when the lastcost for that vendor changed the last time.
1523
1524 So the original values are cloned and remembered, so we can compare if lastcost
1525 was changed in $::form, and keep or update lastupdate.
1526
1527 lastcost isn't updated until the first time it was saved with a value, until
1528 then it is empty.
1529
1530 Also a boolean "makemodel" needs to be written in parts, depending on whether
1531 makemodel entries exist or not.
1532
1533 We still need init_makemodels for when we open the part for editing.
1534
1535 =back
1536
1537 =head1 TODO
1538
1539 =over 4
1540
1541 =item *
1542
1543 It should be possible to jump to the edit page in a specific tab
1544
1545 =item *
1546
1547 Support callbacks, e.g. creating a new part from within an order, and jumping
1548 back to the order again afterwards.
1549
1550 =item *
1551
1552 Support units when adding assembly items or assortment items. Currently the
1553 default unit of the item is always used.
1554
1555 =item *
1556
1557 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1558 consists of other assemblies.
1559
1560 =back
1561
1562 =head1 AUTHOR
1563
1564 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
1565
1566 =cut