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