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