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