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