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