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