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