Zahlungsbedingungen obsolet setzen
[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   my ($self) = @_;
899   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
900 }
901
902 sub init_all_price_factors {
903   SL::DB::Manager::PriceFactor->get_all_sorted;
904 }
905
906 sub init_all_pricegroups {
907   SL::DB::Manager::Pricegroup->get_all_sorted;
908 }
909
910 # model used to filter/display the parts in the multi-items dialog
911 sub init_multi_items_models {
912   SL::Controller::Helper::GetModels->new(
913     controller     => $_[0],
914     model          => 'Part',
915     with_objects   => [ qw(unit_obj partsgroup) ],
916     disable_plugin => 'paginated',
917     source         => $::form->{multi_items},
918     sorted         => {
919       _default    => {
920         by  => 'partnumber',
921         dir => 1,
922       },
923       partnumber  => t8('Partnumber'),
924       description => t8('Description')}
925   );
926 }
927
928 # simple checks to run on $::form before saving
929
930 sub form_check_part_description_exists {
931   my ($self) = @_;
932
933   return 1 if $::form->{part}{description};
934
935   $self->js->flash('error', t8('Part Description missing!'))
936            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
937            ->focus('#part_description');
938   return 0;
939 }
940
941 sub form_check_assortment_items_exist {
942   my ($self) = @_;
943
944   return 1 unless $::form->{part}{part_type} eq 'assortment';
945   # skip check for existing parts that have been used
946   return 1 if ($self->part->id and !$self->part->orphaned);
947
948   # new or orphaned parts must have items in $::form->{assortment_items}
949   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
950     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
951              ->focus('#add_assortment_item_name')
952              ->flash('error', t8('The assortment doesn\'t have any items.'));
953     return 0;
954   };
955   return 1;
956 }
957
958 sub form_check_assortment_items_unique {
959   my ($self) = @_;
960
961   return 1 unless $::form->{part}{part_type} eq 'assortment';
962
963   my %duplicate_elements;
964   my %count;
965   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
966     $duplicate_elements{$_}++ if $count{$_}++;
967   };
968
969   if ( keys %duplicate_elements ) {
970     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
971              ->flash('error', t8('There are duplicate assortment items'));
972     return 0;
973   };
974   return 1;
975 }
976
977 sub form_check_assembly_items_exist {
978   my ($self) = @_;
979
980   return 1 unless $::form->{part}->{part_type} eq 'assembly';
981
982   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
983     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
984              ->focus('#add_assembly_item_name')
985              ->flash('error', t8('The assembly doesn\'t have any items.'));
986     return 0;
987   };
988   return 1;
989 }
990
991 sub form_check_partnumber_is_unique {
992   my ($self) = @_;
993
994   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
995     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
996     if ( $count ) {
997       $self->js->flash('error', t8('The partnumber already exists!'))
998                ->focus('#part_description');
999       return 0;
1000     };
1001   };
1002   return 1;
1003 }
1004
1005 # general checking functions
1006 sub check_next_transnumber_is_free {
1007   my ($self) = @_;
1008
1009   my ($next_transnumber, $count);
1010   $self->part->db->with_transaction(sub {
1011     $next_transnumber = $self->part->get_next_trans_number;
1012     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1013     return 1;
1014   }) or die $@;
1015   $count ? return 0 : return 1;
1016 }
1017
1018 sub check_part_id {
1019   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1020 }
1021
1022 sub check_form {
1023   my ($self) = @_;
1024
1025   $self->form_check_part_description_exists || return 0;
1026   $self->form_check_assortment_items_exist  || return 0;
1027   $self->form_check_assortment_items_unique || return 0;
1028   $self->form_check_assembly_items_exist    || return 0;
1029   $self->form_check_partnumber_is_unique    || return 0;
1030
1031   return 1;
1032 }
1033
1034 sub check_has_valid_part_type {
1035   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1036 }
1037
1038 sub render_assortment_items_to_html {
1039   my ($self, $assortment_items, $number_of_items) = @_;
1040
1041   my $position = $number_of_items + 1;
1042   my $html;
1043   foreach my $ai (@$assortment_items) {
1044     $html .= $self->p->render('part/_assortment_row',
1045                               PART     => $self->part,
1046                               orphaned => $self->orphaned,
1047                               ITEM     => $ai,
1048                               listrow  => $position % 2 ? 1 : 0,
1049                               position => $position, # for legacy assemblies
1050                              );
1051     $position++;
1052   };
1053   return $html;
1054 }
1055
1056 sub render_assembly_items_to_html {
1057   my ($self, $assembly_items, $number_of_items) = @_;
1058
1059   my $position = $number_of_items + 1;
1060   my $html;
1061   foreach my $ai (@{$assembly_items}) {
1062     $html .= $self->p->render('part/_assembly_row',
1063                               PART     => $self->part,
1064                               orphaned => $self->orphaned,
1065                               ITEM     => $ai,
1066                               listrow  => $position % 2 ? 1 : 0,
1067                               position => $position, # for legacy assemblies
1068                              );
1069     $position++;
1070   };
1071   return $html;
1072 }
1073
1074 sub parse_add_items_to_objects {
1075   my ($self, %params) = @_;
1076   my $part_type = $params{part_type};
1077   die unless $params{part_type} =~ /^(assortment|assembly)$/;
1078   my $position = $params{position} || 1;
1079
1080   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1081
1082   my @item_objects;
1083   foreach my $item ( @add_items ) {
1084     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1085     my $ai;
1086     if ( $part_type eq 'assortment' ) {
1087        $ai = SL::DB::AssortmentItem->new(part          => $part,
1088                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1089                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
1090                                          position      => $position,
1091                                         ) or die "Can't create AssortmentItem from item";
1092     } elsif ( $part_type eq 'assembly' ) {
1093       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
1094                                  # id          => $self->assembly->id, # will be set on save
1095                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1096                                  bom         => 0, # default when adding: no bom
1097                                  position    => $position,
1098                                 );
1099     } else {
1100       die "part_type must be assortment or assembly";
1101     }
1102     push(@item_objects, $ai);
1103     $position++;
1104   };
1105
1106   return \@item_objects;
1107 }
1108
1109 1;
1110
1111 __END__
1112
1113 =encoding utf-8
1114
1115 =head1 NAME
1116
1117 SL::Controller::Part - Part CRUD controller
1118
1119 =head1 DESCRIPTION
1120
1121 Controller for adding/editing/saving/deleting parts.
1122
1123 All the relations are loaded at once and saving the part, adding a history
1124 entry and saving CVars happens inside one transaction.  When saving the old
1125 relations are deleted and written as new to the database.
1126
1127 Relations for parts:
1128
1129 =over 2
1130
1131 =item makemodels
1132
1133 =item translations
1134
1135 =item assembly items
1136
1137 =item assortment items
1138
1139 =item prices
1140
1141 =back
1142
1143 =head1 PART_TYPES
1144
1145 There are 4 different part types:
1146
1147 =over 4
1148
1149 =item C<part>
1150
1151 The "default" part type.
1152
1153 inventory_accno_id is set.
1154
1155 =item C<service>
1156
1157 Services can't be stocked.
1158
1159 inventory_accno_id isn't set.
1160
1161 =item C<assembly>
1162
1163 Assemblies consist of other parts, services, assemblies or assortments. They
1164 aren't meant to be bought, only sold. To add assemblies to stock you typically
1165 have to make them, which reduces the stock by its respective components. Once
1166 an assembly item has been created there is currently no way to "disassemble" it
1167 again. An assembly item can appear several times in one assembly. An assmbly is
1168 sold as one item with a defined sellprice and lastcost. If the component prices
1169 change the assortment price remains the same. The assembly items may be printed
1170 in a record if the item's "bom" is set.
1171
1172 =item C<assortment>
1173
1174 Similar to assembly, but each assortment item may only appear once per
1175 assortment. When selling an assortment the assortment items are added to the
1176 record together with the assortment, which is added with sellprice 0.
1177
1178 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1179 determined by the sum of the current assortment item prices when the assortment
1180 is added to a record. This also means that price rules and customer discounts
1181 will be applied to the assortment items.
1182
1183 Once the assortment items have been added they may be modified or deleted, just
1184 as if they had been added manually, the individual assortment items aren't
1185 linked to the assortment or the other assortment items in any way.
1186
1187 =back
1188
1189 =head1 URL ACTIONS
1190
1191 =over 4
1192
1193 =item C<action_add_part>
1194
1195 =item C<action_add_service>
1196
1197 =item C<action_add_assembly>
1198
1199 =item C<action_add_assortment>
1200
1201 =item C<action_add PART_TYPE>
1202
1203 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1204 parameter part_type as an action. Example:
1205
1206   controller.pl?action=Part/add&part_type=service
1207
1208 =item C<action_save>
1209
1210 Saves the current part and then reloads the edit page for the part.
1211
1212 =item C<action_use_as_new>
1213
1214 Takes the information from the current part, plus any modifications made on the
1215 page, and creates a new edit page that is ready to be saved. The partnumber is
1216 set empty, so a new partnumber from the number range will be used if the user
1217 doesn't enter one manually.
1218
1219 Unsaved changes to the original part aren't updated.
1220
1221 The part type cannot be changed in this way.
1222
1223 =item C<action_delete>
1224
1225 Deletes the current part and then redirects to the main page, there is no
1226 callback.
1227
1228 The delete button only appears if the part is 'orphaned', according to
1229 SL::DB::Part orphaned.
1230
1231 The part can't be deleted if it appears in invoices, orders, delivery orders,
1232 the inventory, or is part of an assembly or assortment.
1233
1234 If the part is deleted its relations prices, makdemodel, assembly,
1235 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1236
1237 Before this controller items that appeared in inventory didn't count as
1238 orphaned and could be deleted and the inventory entries were also deleted, this
1239 "feature" hasn't been implemented.
1240
1241 =item C<action_edit part.id>
1242
1243 Load and display a part for editing.
1244
1245   controller.pl?action=Part/edit&part.id=12345
1246
1247 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1248
1249 =back
1250
1251 =head1 BUTTON ACTIONS
1252
1253 =over 4
1254
1255 =item C<history>
1256
1257 Opens a popup displaying all the history entries. Once a new history controller
1258 is written the button could link there instead, with the part already selected.
1259
1260 =back
1261
1262 =head1 AJAX ACTIONS
1263
1264 =over 4
1265
1266 =item C<action_update_item_totals>
1267
1268 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1269 amount of an item changes. The sum of all sellprices and lastcosts is
1270 calculated and the totals updated. Uses C<recalc_item_totals>.
1271
1272 =item C<action_add_assortment_item>
1273
1274 Adds a new assortment item from a part picker seleciton to the assortment item list
1275
1276 If the item already exists in the assortment the item isn't added and a Flash
1277 error shown.
1278
1279 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1280 after adding each new item, add the new object to the item objects that were
1281 already parsed, calculate totals via a dummy part then update the row and the
1282 totals.
1283
1284 =item C<action_add_assembly_item>
1285
1286 Adds a new assembly item from a part picker seleciton to the assembly item list
1287
1288 If the item already exists in the assembly a flash info is generated, but the
1289 item is added.
1290
1291 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1292 after adding each new item, add the new object to the item objects that were
1293 already parsed, calculate totals via a dummy part then update the row and the
1294 totals.
1295
1296 =item C<action_add_multi_assortment_items>
1297
1298 Parses the items to be added from the form generated by the multi input and
1299 appends the html of the tr-rows to the assortment item table. Afterwards all
1300 assortment items are renumbered and the sums recalculated via
1301 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1302
1303 =item C<action_add_multi_assembly_items>
1304
1305 Parses the items to be added from the form generated by the multi input and
1306 appends the html of the tr-rows to the assembly item table. Afterwards all
1307 assembly items are renumbered and the sums recalculated via
1308 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1309
1310 =item C<action_show_multi_items_dialog>
1311
1312 =item C<action_multi_items_update_result>
1313
1314 =item C<action_add_makemodel_row>
1315
1316 Add a new makemodel row with the vendor that was selected via the vendor
1317 picker.
1318
1319 Checks the already existing makemodels and warns if a row with that vendor
1320 already exists. Currently it is possible to have duplicate vendor rows.
1321
1322 =item C<action_reorder_items>
1323
1324 Sorts the item table for assembly or assortment items.
1325
1326 =item C<action_warehouse_changed>
1327
1328 =back
1329
1330 =head1 ACTIONS part picker
1331
1332 =over 4
1333
1334 =item C<action_ajax_autocomplete>
1335
1336 =item C<action_test_page>
1337
1338 =item C<action_part_picker_search>
1339
1340 =item C<action_part_picker_result>
1341
1342 =item C<action_show>
1343
1344 =back
1345
1346 =head1 FORM CHECKS
1347
1348 =over 2
1349
1350 =item C<check_form>
1351
1352 Calls some simple checks that test the submitted $::form for obvious errors.
1353 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1354
1355 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1356 some cases extra actions are taken, e.g. if the part description is missing the
1357 basic data tab is selected and the description input field is focussed.
1358
1359 =back
1360
1361 =over 4
1362
1363 =item C<form_check_part_description_exists>
1364
1365 =item C<form_check_assortment_items_exist>
1366
1367 =item C<form_check_assortment_items_unique>
1368
1369 =item C<form_check_assembly_items_exist>
1370
1371 =item C<form_check_partnumber_is_unique>
1372
1373 =back
1374
1375 =head1 HELPER FUNCTIONS
1376
1377 =over 4
1378
1379 =item C<parse_form>
1380
1381 When submitting the form for saving, parses the transmitted form. Expects the
1382 following data:
1383
1384  $::form->{part}
1385  $::form->{makemodels}
1386  $::form->{translations}
1387  $::form->{prices}
1388  $::form->{assemblies}
1389  $::form->{assortments}
1390
1391 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1392
1393 =item C<recalc_item_totals %params>
1394
1395 Helper function for calculating the total lastcost and sellprice for assemblies
1396 or assortments according to their items, which are parsed from the current
1397 $::form.
1398
1399 Is called whenever the qty of an item is changed or items are deleted.
1400
1401 Takes two params:
1402
1403 * part_type : 'assortment' or 'assembly' (mandatory)
1404
1405 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1406
1407 Depending on the price_type the lastcost sum or sellprice sum is returned.
1408
1409 Doesn't work for recursive items.
1410
1411 =back
1412
1413 =head1 GET SET INITS
1414
1415 There are get_set_inits for
1416
1417 * assembly items
1418
1419 * assortment items
1420
1421 * makemodels
1422
1423 which parse $::form and automatically create an array of objects.
1424
1425 These inits are used during saving and each time a new element is added.
1426
1427 =over 4
1428
1429 =item C<init_makemodels>
1430
1431 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1432 $self->part->makemodels, ready to be saved.
1433
1434 Used for saving parts and adding new makemodel rows.
1435
1436 =item C<parse_add_items_to_objects PART_TYPE>
1437
1438 Parses the resulting form from either the part-picker submit or the multi-item
1439 submit, and creates an arrayref of assortment_item or assembly objects, that
1440 can be rendered via C<render_assortment_items_to_html> or
1441 C<render_assembly_items_to_html>.
1442
1443 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1444 Optional param: position (used for numbering and listrow class)
1445
1446 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1447
1448 Takes an array_ref of assortment_items, and generates tables rows ready for
1449 adding to the assortment table.  Is used when a part is loaded, or whenever new
1450 assortment items are added.
1451
1452 =item C<parse_form_makemodels>
1453
1454 Makemodels can't just be overwritten, because of the field "lastupdate", that
1455 remembers when the lastcost for that vendor changed the last time.
1456
1457 So the original values are cloned and remembered, so we can compare if lastcost
1458 was changed in $::form, and keep or update lastupdate.
1459
1460 lastcost isn't updated until the first time it was saved with a value, until
1461 then it is empty.
1462
1463 Also a boolean "makemodel" needs to be written in parts, depending on whether
1464 makemodel entries exist or not.
1465
1466 We still need init_makemodels for when we open the part for editing.
1467
1468 =back
1469
1470 =head1 TODO
1471
1472 =over 4
1473
1474 =item *
1475
1476 It should be possible to jump to the edit page in a specific tab
1477
1478 =item *
1479
1480 Support callbacks, e.g. creating a new part from within an order, and jumping
1481 back to the order again afterwards.
1482
1483 =item *
1484
1485 Support units when adding assembly items or assortment items. Currently the
1486 default unit of the item is always used.
1487
1488 =item *
1489
1490 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1491 consists of other assemblies.
1492
1493 =back
1494
1495 =head1 AUTHOR
1496
1497 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
1498
1499 =cut