ActionBar: Verwendung im Part-Controller
[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         ],
1143         action => [
1144           t8('Use as new'),
1145           call     => [ 'kivi.Part.use_as_new' ],
1146           disabled => !$self->part->id ? t8('The object has not been saved yet.')
1147                     : !$may_edit       ? t8('You do not have the permissions to access this function.')
1148                     :                    undef,
1149         ],
1150       ], # end of combobox "Save"
1151
1152       action => [
1153         t8('Delete'),
1154         call     => [ 'kivi.Part.delete' ],
1155         confirm  => t8('Do you really want to delete this object?'),
1156         disabled => !$self->part->id       ? t8('This object has not been saved yet.')
1157                   : !$may_edit             ? t8('You do not have the permissions to access this function.')
1158                   : !$self->part->orphaned ? t8('This object has already been used.')
1159                   :                          undef,
1160       ],
1161
1162       'separator',
1163
1164       action => [
1165         t8('History'),
1166         call     => [ 'kivi.Part.open_history_popup' ],
1167         disabled => !$self->part->id ? t8('This object has not been saved yet.')
1168                   : !$may_edit       ? t8('You do not have the permissions to access this function.')
1169                   :                    undef,
1170       ],
1171     );
1172   }
1173 }
1174
1175 1;
1176
1177 __END__
1178
1179 =encoding utf-8
1180
1181 =head1 NAME
1182
1183 SL::Controller::Part - Part CRUD controller
1184
1185 =head1 DESCRIPTION
1186
1187 Controller for adding/editing/saving/deleting parts.
1188
1189 All the relations are loaded at once and saving the part, adding a history
1190 entry and saving CVars happens inside one transaction.  When saving the old
1191 relations are deleted and written as new to the database.
1192
1193 Relations for parts:
1194
1195 =over 2
1196
1197 =item makemodels
1198
1199 =item translations
1200
1201 =item assembly items
1202
1203 =item assortment items
1204
1205 =item prices
1206
1207 =back
1208
1209 =head1 PART_TYPES
1210
1211 There are 4 different part types:
1212
1213 =over 4
1214
1215 =item C<part>
1216
1217 The "default" part type.
1218
1219 inventory_accno_id is set.
1220
1221 =item C<service>
1222
1223 Services can't be stocked.
1224
1225 inventory_accno_id isn't set.
1226
1227 =item C<assembly>
1228
1229 Assemblies consist of other parts, services, assemblies or assortments. They
1230 aren't meant to be bought, only sold. To add assemblies to stock you typically
1231 have to make them, which reduces the stock by its respective components. Once
1232 an assembly item has been created there is currently no way to "disassemble" it
1233 again. An assembly item can appear several times in one assembly. An assmbly is
1234 sold as one item with a defined sellprice and lastcost. If the component prices
1235 change the assortment price remains the same. The assembly items may be printed
1236 in a record if the item's "bom" is set.
1237
1238 =item C<assortment>
1239
1240 Similar to assembly, but each assortment item may only appear once per
1241 assortment. When selling an assortment the assortment items are added to the
1242 record together with the assortment, which is added with sellprice 0.
1243
1244 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1245 determined by the sum of the current assortment item prices when the assortment
1246 is added to a record. This also means that price rules and customer discounts
1247 will be applied to the assortment items.
1248
1249 Once the assortment items have been added they may be modified or deleted, just
1250 as if they had been added manually, the individual assortment items aren't
1251 linked to the assortment or the other assortment items in any way.
1252
1253 =back
1254
1255 =head1 URL ACTIONS
1256
1257 =over 4
1258
1259 =item C<action_add_part>
1260
1261 =item C<action_add_service>
1262
1263 =item C<action_add_assembly>
1264
1265 =item C<action_add_assortment>
1266
1267 =item C<action_add PART_TYPE>
1268
1269 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1270 parameter part_type as an action. Example:
1271
1272   controller.pl?action=Part/add&part_type=service
1273
1274 =item C<action_save>
1275
1276 Saves the current part and then reloads the edit page for the part.
1277
1278 =item C<action_use_as_new>
1279
1280 Takes the information from the current part, plus any modifications made on the
1281 page, and creates a new edit page that is ready to be saved. The partnumber is
1282 set empty, so a new partnumber from the number range will be used if the user
1283 doesn't enter one manually.
1284
1285 Unsaved changes to the original part aren't updated.
1286
1287 The part type cannot be changed in this way.
1288
1289 =item C<action_delete>
1290
1291 Deletes the current part and then redirects to the main page, there is no
1292 callback.
1293
1294 The delete button only appears if the part is 'orphaned', according to
1295 SL::DB::Part orphaned.
1296
1297 The part can't be deleted if it appears in invoices, orders, delivery orders,
1298 the inventory, or is part of an assembly or assortment.
1299
1300 If the part is deleted its relations prices, makdemodel, assembly,
1301 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1302
1303 Before this controller items that appeared in inventory didn't count as
1304 orphaned and could be deleted and the inventory entries were also deleted, this
1305 "feature" hasn't been implemented.
1306
1307 =item C<action_edit part.id>
1308
1309 Load and display a part for editing.
1310
1311   controller.pl?action=Part/edit&part.id=12345
1312
1313 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1314
1315 =back
1316
1317 =head1 BUTTON ACTIONS
1318
1319 =over 4
1320
1321 =item C<history>
1322
1323 Opens a popup displaying all the history entries. Once a new history controller
1324 is written the button could link there instead, with the part already selected.
1325
1326 =back
1327
1328 =head1 AJAX ACTIONS
1329
1330 =over 4
1331
1332 =item C<action_update_item_totals>
1333
1334 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1335 amount of an item changes. The sum of all sellprices and lastcosts is
1336 calculated and the totals updated. Uses C<recalc_item_totals>.
1337
1338 =item C<action_add_assortment_item>
1339
1340 Adds a new assortment item from a part picker seleciton to the assortment item list
1341
1342 If the item already exists in the assortment the item isn't added and a Flash
1343 error shown.
1344
1345 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1346 after adding each new item, add the new object to the item objects that were
1347 already parsed, calculate totals via a dummy part then update the row and the
1348 totals.
1349
1350 =item C<action_add_assembly_item>
1351
1352 Adds a new assembly item from a part picker seleciton to the assembly item list
1353
1354 If the item already exists in the assembly a flash info is generated, but the
1355 item is added.
1356
1357 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1358 after adding each new item, add the new object to the item objects that were
1359 already parsed, calculate totals via a dummy part then update the row and the
1360 totals.
1361
1362 =item C<action_add_multi_assortment_items>
1363
1364 Parses the items to be added from the form generated by the multi input and
1365 appends the html of the tr-rows to the assortment item table. Afterwards all
1366 assortment items are renumbered and the sums recalculated via
1367 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1368
1369 =item C<action_add_multi_assembly_items>
1370
1371 Parses the items to be added from the form generated by the multi input and
1372 appends the html of the tr-rows to the assembly item table. Afterwards all
1373 assembly items are renumbered and the sums recalculated via
1374 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1375
1376 =item C<action_show_multi_items_dialog>
1377
1378 =item C<action_multi_items_update_result>
1379
1380 =item C<action_add_makemodel_row>
1381
1382 Add a new makemodel row with the vendor that was selected via the vendor
1383 picker.
1384
1385 Checks the already existing makemodels and warns if a row with that vendor
1386 already exists. Currently it is possible to have duplicate vendor rows.
1387
1388 =item C<action_reorder_items>
1389
1390 Sorts the item table for assembly or assortment items.
1391
1392 =item C<action_warehouse_changed>
1393
1394 =back
1395
1396 =head1 ACTIONS part picker
1397
1398 =over 4
1399
1400 =item C<action_ajax_autocomplete>
1401
1402 =item C<action_test_page>
1403
1404 =item C<action_part_picker_search>
1405
1406 =item C<action_part_picker_result>
1407
1408 =item C<action_show>
1409
1410 =back
1411
1412 =head1 FORM CHECKS
1413
1414 =over 2
1415
1416 =item C<check_form>
1417
1418 Calls some simple checks that test the submitted $::form for obvious errors.
1419 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1420
1421 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1422 some cases extra actions are taken, e.g. if the part description is missing the
1423 basic data tab is selected and the description input field is focussed.
1424
1425 =back
1426
1427 =over 4
1428
1429 =item C<form_check_part_description_exists>
1430
1431 =item C<form_check_assortment_items_exist>
1432
1433 =item C<form_check_assortment_items_unique>
1434
1435 =item C<form_check_assembly_items_exist>
1436
1437 =item C<form_check_partnumber_is_unique>
1438
1439 =back
1440
1441 =head1 HELPER FUNCTIONS
1442
1443 =over 4
1444
1445 =item C<parse_form>
1446
1447 When submitting the form for saving, parses the transmitted form. Expects the
1448 following data:
1449
1450  $::form->{part}
1451  $::form->{makemodels}
1452  $::form->{translations}
1453  $::form->{prices}
1454  $::form->{assemblies}
1455  $::form->{assortments}
1456
1457 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1458
1459 =item C<recalc_item_totals %params>
1460
1461 Helper function for calculating the total lastcost and sellprice for assemblies
1462 or assortments according to their items, which are parsed from the current
1463 $::form.
1464
1465 Is called whenever the qty of an item is changed or items are deleted.
1466
1467 Takes two params:
1468
1469 * part_type : 'assortment' or 'assembly' (mandatory)
1470
1471 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1472
1473 Depending on the price_type the lastcost sum or sellprice sum is returned.
1474
1475 Doesn't work for recursive items.
1476
1477 =back
1478
1479 =head1 GET SET INITS
1480
1481 There are get_set_inits for
1482
1483 * assembly items
1484
1485 * assortment items
1486
1487 * makemodels
1488
1489 which parse $::form and automatically create an array of objects.
1490
1491 These inits are used during saving and each time a new element is added.
1492
1493 =over 4
1494
1495 =item C<init_makemodels>
1496
1497 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1498 $self->part->makemodels, ready to be saved.
1499
1500 Used for saving parts and adding new makemodel rows.
1501
1502 =item C<parse_add_items_to_objects PART_TYPE>
1503
1504 Parses the resulting form from either the part-picker submit or the multi-item
1505 submit, and creates an arrayref of assortment_item or assembly objects, that
1506 can be rendered via C<render_assortment_items_to_html> or
1507 C<render_assembly_items_to_html>.
1508
1509 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1510 Optional param: position (used for numbering and listrow class)
1511
1512 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1513
1514 Takes an array_ref of assortment_items, and generates tables rows ready for
1515 adding to the assortment table.  Is used when a part is loaded, or whenever new
1516 assortment items are added.
1517
1518 =item C<parse_form_makemodels>
1519
1520 Makemodels can't just be overwritten, because of the field "lastupdate", that
1521 remembers when the lastcost for that vendor changed the last time.
1522
1523 So the original values are cloned and remembered, so we can compare if lastcost
1524 was changed in $::form, and keep or update lastupdate.
1525
1526 lastcost isn't updated until the first time it was saved with a value, until
1527 then it is empty.
1528
1529 Also a boolean "makemodel" needs to be written in parts, depending on whether
1530 makemodel entries exist or not.
1531
1532 We still need init_makemodels for when we open the part for editing.
1533
1534 =back
1535
1536 =head1 TODO
1537
1538 =over 4
1539
1540 =item *
1541
1542 It should be possible to jump to the edit page in a specific tab
1543
1544 =item *
1545
1546 Support callbacks, e.g. creating a new part from within an order, and jumping
1547 back to the order again afterwards.
1548
1549 =item *
1550
1551 Support units when adding assembly items or assortment items. Currently the
1552 default unit of the item is always used.
1553
1554 =item *
1555
1556 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1557 consists of other assemblies.
1558
1559 =back
1560
1561 =head1 AUTHOR
1562
1563 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
1564
1565 =cut