Abfangen von leerer warehouse_id bevor Objekt gesucht wird
[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   if ($::form->{warehouse_id} ) {
461     $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
462     die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
463
464     if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
465       $self->bin($self->warehouse->bins->[0]);
466       $self->js
467         ->html('#bin', $self->build_bin_select)
468         ->focus('#part_bin_id');
469       return $self->js->render;
470     }
471   }
472
473   # no warehouse was selected, empty the bin field and reset the id
474   $self->js
475        ->val('#part_bin_id', undef)
476        ->html('#bin', '');
477
478   return $self->js->render;
479 }
480
481 sub action_ajax_autocomplete {
482   my ($self, %params) = @_;
483
484   # if someone types something, and hits enter, assume he entered the full name.
485   # if something matches, treat that as sole match
486   # unfortunately get_models can't do more than one per package atm, so we d it
487   # the oldfashioned way.
488   if ($::form->{prefer_exact}) {
489     my $exact_matches;
490     if (1 == scalar @{ $exact_matches = SL::DB::Manager::Part->get_all(
491       query => [
492         obsolete => 0,
493         SL::DB::Manager::Part->type_filter($::form->{filter}{part_type}),
494         or => [
495           description => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
496           partnumber  => { ilike => $::form->{filter}{'all:substr:multi::ilike'} },
497         ]
498       ],
499       limit => 2,
500     ) }) {
501       $self->parts($exact_matches);
502     }
503   }
504
505   my @hashes = map {
506    +{
507      value       => $_->displayable_name,
508      label       => $_->displayable_name,
509      id          => $_->id,
510      partnumber  => $_->partnumber,
511      description => $_->description,
512      part_type   => $_->part_type,
513      unit        => $_->unit,
514      cvars       => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
515     }
516   } @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts
517
518   $self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
519 }
520
521 sub action_test_page {
522   $_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
523 }
524
525 sub action_part_picker_search {
526   $_[0]->render('part/part_picker_search', { layout => 0 }, parts => $_[0]->parts);
527 }
528
529 sub action_part_picker_result {
530   $_[0]->render('part/_part_picker_result', { layout => 0 });
531 }
532
533 sub action_show {
534   my ($self) = @_;
535
536   if ($::request->type eq 'json') {
537     my $part_hash;
538     if (!$self->part) {
539       # TODO error
540     } else {
541       $part_hash          = $self->part->as_tree;
542       $part_hash->{cvars} = $self->part->cvar_as_hashref;
543     }
544
545     $self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
546   }
547 }
548
549 # helper functions
550 sub validate_add_items {
551   scalar @{$::form->{add_items}};
552 }
553
554 sub prepare_assortment_render_vars {
555   my ($self) = @_;
556
557   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
558                items_lastcost_sum  => $self->part->items_lastcost_sum,
559                assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
560              );
561   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
562
563   return \%vars;
564 }
565
566 sub prepare_assembly_render_vars {
567   my ($self) = @_;
568
569   my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
570                items_lastcost_sum  => $self->part->items_lastcost_sum,
571                assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
572              );
573   $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
574
575   return \%vars;
576 }
577
578 sub add {
579   my ($self) = @_;
580
581   check_has_valid_part_type($self->part->part_type);
582
583   $self->_set_javascript;
584
585   my %title_hash = ( part       => t8('Add Part'),
586                      assembly   => t8('Add Assembly'),
587                      service    => t8('Add Service'),
588                      assortment => t8('Add Assortment'),
589                    );
590
591   $self->render(
592     'part/form',
593     title             => $title_hash{$self->part->part_type},
594     show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
595   );
596 }
597
598
599 sub _set_javascript {
600   my ($self) = @_;
601   $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
602   $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
603 }
604
605 sub recalc_item_totals {
606   my ($self, %params) = @_;
607
608   if ( $params{part_type} eq 'assortment' ) {
609     return 0 unless scalar @{$self->assortment_items};
610   } elsif ( $params{part_type} eq 'assembly' ) {
611     return 0 unless scalar @{$self->assembly_items};
612   } else {
613     carp "can only calculate sum for assortments and assemblies";
614   };
615
616   my $part = SL::DB::Part->new(part_type => $params{part_type});
617   if ( $part->is_assortment ) {
618     $part->assortment_items( @{$self->assortment_items} );
619     if ( $params{price_type} eq 'lastcost' ) {
620       return $part->items_lastcost_sum;
621     } else {
622       if ( $params{pricegroup_id} ) {
623         return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
624       } else {
625         return $part->items_sellprice_sum;
626       };
627     }
628   } elsif ( $part->is_assembly ) {
629     $part->assemblies( @{$self->assembly_items} );
630     if ( $params{price_type} eq 'lastcost' ) {
631       return $part->items_lastcost_sum;
632     } else {
633       return $part->items_sellprice_sum;
634     }
635   }
636 }
637
638 sub check_part_not_modified {
639   my ($self) = @_;
640
641   return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
642
643 }
644
645 sub parse_form {
646   my ($self) = @_;
647
648   my $is_new = !$self->part->id;
649
650   my $params = delete($::form->{part}) || { };
651
652   delete $params->{id};
653   # never overwrite existing partnumber, should be a read-only field anyway
654   delete $params->{partnumber} if $self->part->partnumber;
655   $self->part->assign_attributes(%{ $params});
656   $self->part->bin_id(undef) unless $self->part->warehouse_id;
657
658   # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
659   # will be the case for used assortments when saving, or when a used assortment
660   # is "used as new"
661   if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
662     $self->part->assortment_items([]);
663     $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
664   };
665
666   if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
667     $self->part->assemblies([]); # completely rewrite assortments each time
668     $self->part->add_assemblies( @{ $self->assembly_items } );
669   };
670
671   $self->part->translations([]);
672   $self->parse_form_translations;
673
674   $self->part->prices([]);
675   $self->parse_form_prices;
676
677   $self->parse_form_makemodels;
678 }
679
680 sub parse_form_prices {
681   my ($self) = @_;
682   # only save prices > 0
683   my $prices = delete($::form->{prices}) || [];
684   foreach my $price ( @{$prices} ) {
685     my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
686     next unless $sellprice > 0; # skip negative prices as well
687     my $p = SL::DB::Price->new(parts_id      => $self->part->id,
688                                pricegroup_id => $price->{pricegroup_id},
689                                price         => $sellprice,
690                               );
691     $self->part->add_prices($p);
692   };
693 }
694
695 sub parse_form_translations {
696   my ($self) = @_;
697   # don't add empty translations
698   my $translations = delete($::form->{translations}) || [];
699   foreach my $translation ( @{$translations} ) {
700     next unless $translation->{translation};
701     my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
702     $self->part->add_translations( $translation );
703   };
704 }
705
706 sub parse_form_makemodels {
707   my ($self) = @_;
708
709   my $makemodels_map;
710   if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
711     $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
712   };
713
714   $self->part->makemodels([]);
715
716   my $position = 0;
717   my $makemodels = delete($::form->{makemodels}) || [];
718   foreach my $makemodel ( @{$makemodels} ) {
719     next unless $makemodel->{make};
720     $position++;
721     my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
722
723     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
724                                      id         => $makemodel->{id},
725                                      make       => $makemodel->{make},
726                                      model      => $makemodel->{model} || '',
727                                      lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
728                                      sortorder  => $position,
729                                    );
730     if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
731       # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
732       # don't change lastupdate
733     } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
734       # new makemodel, no lastcost entered, leave lastupdate empty
735     } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
736       # lastcost hasn't changed, use original lastupdate
737       $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
738     } else {
739       $mm->lastupdate(DateTime->now);
740     };
741     $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
742     $self->part->add_makemodels($mm);
743   };
744 }
745
746 sub build_bin_select {
747   $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
748     title_key => 'description',
749     default   => $_[0]->bin->id,
750   );
751 }
752
753 # get_set_inits for partpicker
754
755 sub init_parts {
756   if ($::form->{no_paginate}) {
757     $_[0]->models->disable_plugin('paginated');
758   }
759
760   $_[0]->models->get;
761 }
762
763 # get_set_inits for part controller
764 sub init_part {
765   my ($self) = @_;
766
767   # used by edit, save, delete and add
768
769   if ( $::form->{part}{id} ) {
770     return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
771   } else {
772     die "part_type missing" unless $::form->{part}{part_type};
773     return SL::DB::Part->new(part_type => $::form->{part}{part_type});
774   };
775 }
776
777 sub init_orphaned {
778   my ($self) = @_;
779   return $self->part->orphaned;
780 }
781
782 sub init_models {
783   my ($self) = @_;
784
785   SL::Controller::Helper::GetModels->new(
786     controller => $self,
787     sorted => {
788       _default  => {
789         by => 'partnumber',
790         dir  => 1,
791       },
792       partnumber  => t8('Partnumber'),
793       description  => t8('Description'),
794     },
795     with_objects => [ qw(unit_obj) ],
796   );
797 }
798
799 sub init_p {
800   SL::Presenter->get;
801 }
802
803
804 sub init_assortment_items {
805   # this init is used while saving and whenever assortments change dynamically
806   my ($self) = @_;
807   my $position = 0;
808   my @array;
809   my $assortment_items = delete($::form->{assortment_items}) || [];
810   foreach my $assortment_item ( @{$assortment_items} ) {
811     next unless $assortment_item->{parts_id};
812     $position++;
813     my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
814     my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
815                                           qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
816                                           charge        => $assortment_item->{charge},
817                                           unit          => $assortment_item->{unit} || $part->unit,
818                                           position      => $position,
819     );
820
821     push(@array, $ai);
822   };
823   return \@array;
824 }
825
826 sub init_makemodels {
827   my ($self) = @_;
828
829   my $position = 0;
830   my @makemodel_array = ();
831   my $makemodels = delete($::form->{makemodels}) || [];
832
833   foreach my $makemodel ( @{$makemodels} ) {
834     next unless $makemodel->{make};
835     $position++;
836     my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
837                                     id        => $makemodel->{id},
838                                     make      => $makemodel->{make},
839                                     model     => $makemodel->{model} || '',
840                                     lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
841                                     sortorder => $position,
842                                   ) or die "Can't create mm";
843     # $mm->id($makemodel->{id}) if $makemodel->{id};
844     push(@makemodel_array, $mm);
845   };
846   return \@makemodel_array;
847 }
848
849 sub init_assembly_items {
850   my ($self) = @_;
851   my $position = 0;
852   my @array;
853   my $assembly_items = delete($::form->{assembly_items}) || [];
854   foreach my $assembly_item ( @{$assembly_items} ) {
855     next unless $assembly_item->{parts_id};
856     $position++;
857     my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
858     my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
859                                    bom         => $assembly_item->{bom},
860                                    qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
861                                    position    => $position,
862                                   );
863     push(@array, $ai);
864   };
865   return \@array;
866 }
867
868 sub init_all_warehouses {
869   my ($self) = @_;
870   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
871 }
872
873 sub init_all_languages {
874   SL::DB::Manager::Language->get_all_sorted;
875 }
876
877 sub init_all_partsgroups {
878   my ($self) = @_;
879   SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
880 }
881
882 sub init_all_buchungsgruppen {
883   my ($self) = @_;
884   if ( $self->part->orphaned ) {
885     return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
886   } else {
887     return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
888   }
889 }
890
891 sub init_all_units {
892   my ($self) = @_;
893   if ( $self->part->orphaned ) {
894     return SL::DB::Manager::Unit->get_all_sorted;
895   } else {
896     return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
897   }
898 }
899
900 sub init_all_payment_terms {
901   my ($self) = @_;
902   SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ id => $self->part->payment_id, obsolete => 0 ] ]);
903 }
904
905 sub init_all_price_factors {
906   SL::DB::Manager::PriceFactor->get_all_sorted;
907 }
908
909 sub init_all_pricegroups {
910   SL::DB::Manager::Pricegroup->get_all_sorted;
911 }
912
913 # model used to filter/display the parts in the multi-items dialog
914 sub init_multi_items_models {
915   SL::Controller::Helper::GetModels->new(
916     controller     => $_[0],
917     model          => 'Part',
918     with_objects   => [ qw(unit_obj partsgroup) ],
919     disable_plugin => 'paginated',
920     source         => $::form->{multi_items},
921     sorted         => {
922       _default    => {
923         by  => 'partnumber',
924         dir => 1,
925       },
926       partnumber  => t8('Partnumber'),
927       description => t8('Description')}
928   );
929 }
930
931 # simple checks to run on $::form before saving
932
933 sub form_check_part_description_exists {
934   my ($self) = @_;
935
936   return 1 if $::form->{part}{description};
937
938   $self->js->flash('error', t8('Part Description missing!'))
939            ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
940            ->focus('#part_description');
941   return 0;
942 }
943
944 sub form_check_assortment_items_exist {
945   my ($self) = @_;
946
947   return 1 unless $::form->{part}{part_type} eq 'assortment';
948   # skip check for existing parts that have been used
949   return 1 if ($self->part->id and !$self->part->orphaned);
950
951   # new or orphaned parts must have items in $::form->{assortment_items}
952   unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
953     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
954              ->focus('#add_assortment_item_name')
955              ->flash('error', t8('The assortment doesn\'t have any items.'));
956     return 0;
957   };
958   return 1;
959 }
960
961 sub form_check_assortment_items_unique {
962   my ($self) = @_;
963
964   return 1 unless $::form->{part}{part_type} eq 'assortment';
965
966   my %duplicate_elements;
967   my %count;
968   for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
969     $duplicate_elements{$_}++ if $count{$_}++;
970   };
971
972   if ( keys %duplicate_elements ) {
973     $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
974              ->flash('error', t8('There are duplicate assortment items'));
975     return 0;
976   };
977   return 1;
978 }
979
980 sub form_check_assembly_items_exist {
981   my ($self) = @_;
982
983   return 1 unless $::form->{part}->{part_type} eq 'assembly';
984
985   unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
986     $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
987              ->focus('#add_assembly_item_name')
988              ->flash('error', t8('The assembly doesn\'t have any items.'));
989     return 0;
990   };
991   return 1;
992 }
993
994 sub form_check_partnumber_is_unique {
995   my ($self) = @_;
996
997   if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
998     my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
999     if ( $count ) {
1000       $self->js->flash('error', t8('The partnumber already exists!'))
1001                ->focus('#part_description');
1002       return 0;
1003     };
1004   };
1005   return 1;
1006 }
1007
1008 # general checking functions
1009 sub check_next_transnumber_is_free {
1010   my ($self) = @_;
1011
1012   my ($next_transnumber, $count);
1013   $self->part->db->with_transaction(sub {
1014     $next_transnumber = $self->part->get_next_trans_number;
1015     $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
1016     return 1;
1017   }) or die $@;
1018   $count ? return 0 : return 1;
1019 }
1020
1021 sub check_part_id {
1022   die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
1023 }
1024
1025 sub check_form {
1026   my ($self) = @_;
1027
1028   $self->form_check_part_description_exists || return 0;
1029   $self->form_check_assortment_items_exist  || return 0;
1030   $self->form_check_assortment_items_unique || return 0;
1031   $self->form_check_assembly_items_exist    || return 0;
1032   $self->form_check_partnumber_is_unique    || return 0;
1033
1034   return 1;
1035 }
1036
1037 sub check_has_valid_part_type {
1038   die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
1039 }
1040
1041 sub render_assortment_items_to_html {
1042   my ($self, $assortment_items, $number_of_items) = @_;
1043
1044   my $position = $number_of_items + 1;
1045   my $html;
1046   foreach my $ai (@$assortment_items) {
1047     $html .= $self->p->render('part/_assortment_row',
1048                               PART     => $self->part,
1049                               orphaned => $self->orphaned,
1050                               ITEM     => $ai,
1051                               listrow  => $position % 2 ? 1 : 0,
1052                               position => $position, # for legacy assemblies
1053                              );
1054     $position++;
1055   };
1056   return $html;
1057 }
1058
1059 sub render_assembly_items_to_html {
1060   my ($self, $assembly_items, $number_of_items) = @_;
1061
1062   my $position = $number_of_items + 1;
1063   my $html;
1064   foreach my $ai (@{$assembly_items}) {
1065     $html .= $self->p->render('part/_assembly_row',
1066                               PART     => $self->part,
1067                               orphaned => $self->orphaned,
1068                               ITEM     => $ai,
1069                               listrow  => $position % 2 ? 1 : 0,
1070                               position => $position, # for legacy assemblies
1071                              );
1072     $position++;
1073   };
1074   return $html;
1075 }
1076
1077 sub parse_add_items_to_objects {
1078   my ($self, %params) = @_;
1079   my $part_type = $params{part_type};
1080   die unless $params{part_type} =~ /^(assortment|assembly)$/;
1081   my $position = $params{position} || 1;
1082
1083   my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1084
1085   my @item_objects;
1086   foreach my $item ( @add_items ) {
1087     my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
1088     my $ai;
1089     if ( $part_type eq 'assortment' ) {
1090        $ai = SL::DB::AssortmentItem->new(part          => $part,
1091                                          qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1092                                          unit          => $part->unit, # TODO: $item->{unit} || $part->unit
1093                                          position      => $position,
1094                                         ) or die "Can't create AssortmentItem from item";
1095     } elsif ( $part_type eq 'assembly' ) {
1096       $ai = SL::DB::Assembly->new(parts_id    => $part->id,
1097                                  # id          => $self->assembly->id, # will be set on save
1098                                  qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
1099                                  bom         => 0, # default when adding: no bom
1100                                  position    => $position,
1101                                 );
1102     } else {
1103       die "part_type must be assortment or assembly";
1104     }
1105     push(@item_objects, $ai);
1106     $position++;
1107   };
1108
1109   return \@item_objects;
1110 }
1111
1112 1;
1113
1114 __END__
1115
1116 =encoding utf-8
1117
1118 =head1 NAME
1119
1120 SL::Controller::Part - Part CRUD controller
1121
1122 =head1 DESCRIPTION
1123
1124 Controller for adding/editing/saving/deleting parts.
1125
1126 All the relations are loaded at once and saving the part, adding a history
1127 entry and saving CVars happens inside one transaction.  When saving the old
1128 relations are deleted and written as new to the database.
1129
1130 Relations for parts:
1131
1132 =over 2
1133
1134 =item makemodels
1135
1136 =item translations
1137
1138 =item assembly items
1139
1140 =item assortment items
1141
1142 =item prices
1143
1144 =back
1145
1146 =head1 PART_TYPES
1147
1148 There are 4 different part types:
1149
1150 =over 4
1151
1152 =item C<part>
1153
1154 The "default" part type.
1155
1156 inventory_accno_id is set.
1157
1158 =item C<service>
1159
1160 Services can't be stocked.
1161
1162 inventory_accno_id isn't set.
1163
1164 =item C<assembly>
1165
1166 Assemblies consist of other parts, services, assemblies or assortments. They
1167 aren't meant to be bought, only sold. To add assemblies to stock you typically
1168 have to make them, which reduces the stock by its respective components. Once
1169 an assembly item has been created there is currently no way to "disassemble" it
1170 again. An assembly item can appear several times in one assembly. An assmbly is
1171 sold as one item with a defined sellprice and lastcost. If the component prices
1172 change the assortment price remains the same. The assembly items may be printed
1173 in a record if the item's "bom" is set.
1174
1175 =item C<assortment>
1176
1177 Similar to assembly, but each assortment item may only appear once per
1178 assortment. When selling an assortment the assortment items are added to the
1179 record together with the assortment, which is added with sellprice 0.
1180
1181 Technically an assortment doesn't have a sellprice, but rather the sellprice is
1182 determined by the sum of the current assortment item prices when the assortment
1183 is added to a record. This also means that price rules and customer discounts
1184 will be applied to the assortment items.
1185
1186 Once the assortment items have been added they may be modified or deleted, just
1187 as if they had been added manually, the individual assortment items aren't
1188 linked to the assortment or the other assortment items in any way.
1189
1190 =back
1191
1192 =head1 URL ACTIONS
1193
1194 =over 4
1195
1196 =item C<action_add_part>
1197
1198 =item C<action_add_service>
1199
1200 =item C<action_add_assembly>
1201
1202 =item C<action_add_assortment>
1203
1204 =item C<action_add PART_TYPE>
1205
1206 An alternative to the action_add_$PART_TYPE actions, takes the mandatory
1207 parameter part_type as an action. Example:
1208
1209   controller.pl?action=Part/add&part_type=service
1210
1211 =item C<action_save>
1212
1213 Saves the current part and then reloads the edit page for the part.
1214
1215 =item C<action_use_as_new>
1216
1217 Takes the information from the current part, plus any modifications made on the
1218 page, and creates a new edit page that is ready to be saved. The partnumber is
1219 set empty, so a new partnumber from the number range will be used if the user
1220 doesn't enter one manually.
1221
1222 Unsaved changes to the original part aren't updated.
1223
1224 The part type cannot be changed in this way.
1225
1226 =item C<action_delete>
1227
1228 Deletes the current part and then redirects to the main page, there is no
1229 callback.
1230
1231 The delete button only appears if the part is 'orphaned', according to
1232 SL::DB::Part orphaned.
1233
1234 The part can't be deleted if it appears in invoices, orders, delivery orders,
1235 the inventory, or is part of an assembly or assortment.
1236
1237 If the part is deleted its relations prices, makdemodel, assembly,
1238 assortment_items and translation are are also deleted via DELETE ON CASCADE.
1239
1240 Before this controller items that appeared in inventory didn't count as
1241 orphaned and could be deleted and the inventory entries were also deleted, this
1242 "feature" hasn't been implemented.
1243
1244 =item C<action_edit part.id>
1245
1246 Load and display a part for editing.
1247
1248   controller.pl?action=Part/edit&part.id=12345
1249
1250 Passing the part id is mandatory, and the parameter is "part.id", not "id".
1251
1252 =back
1253
1254 =head1 BUTTON ACTIONS
1255
1256 =over 4
1257
1258 =item C<history>
1259
1260 Opens a popup displaying all the history entries. Once a new history controller
1261 is written the button could link there instead, with the part already selected.
1262
1263 =back
1264
1265 =head1 AJAX ACTIONS
1266
1267 =over 4
1268
1269 =item C<action_update_item_totals>
1270
1271 Is called whenever an element with the .recalc class loses focus, e.g. the qty
1272 amount of an item changes. The sum of all sellprices and lastcosts is
1273 calculated and the totals updated. Uses C<recalc_item_totals>.
1274
1275 =item C<action_add_assortment_item>
1276
1277 Adds a new assortment item from a part picker seleciton to the assortment item list
1278
1279 If the item already exists in the assortment the item isn't added and a Flash
1280 error shown.
1281
1282 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1283 after adding each new item, add the new object to the item objects that were
1284 already parsed, calculate totals via a dummy part then update the row and the
1285 totals.
1286
1287 =item C<action_add_assembly_item>
1288
1289 Adds a new assembly item from a part picker seleciton to the assembly item list
1290
1291 If the item already exists in the assembly a flash info is generated, but the
1292 item is added.
1293
1294 Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
1295 after adding each new item, add the new object to the item objects that were
1296 already parsed, calculate totals via a dummy part then update the row and the
1297 totals.
1298
1299 =item C<action_add_multi_assortment_items>
1300
1301 Parses the items to be added from the form generated by the multi input and
1302 appends the html of the tr-rows to the assortment item table. Afterwards all
1303 assortment items are renumbered and the sums recalculated via
1304 kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
1305
1306 =item C<action_add_multi_assembly_items>
1307
1308 Parses the items to be added from the form generated by the multi input and
1309 appends the html of the tr-rows to the assembly item table. Afterwards all
1310 assembly items are renumbered and the sums recalculated via
1311 kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
1312
1313 =item C<action_show_multi_items_dialog>
1314
1315 =item C<action_multi_items_update_result>
1316
1317 =item C<action_add_makemodel_row>
1318
1319 Add a new makemodel row with the vendor that was selected via the vendor
1320 picker.
1321
1322 Checks the already existing makemodels and warns if a row with that vendor
1323 already exists. Currently it is possible to have duplicate vendor rows.
1324
1325 =item C<action_reorder_items>
1326
1327 Sorts the item table for assembly or assortment items.
1328
1329 =item C<action_warehouse_changed>
1330
1331 =back
1332
1333 =head1 ACTIONS part picker
1334
1335 =over 4
1336
1337 =item C<action_ajax_autocomplete>
1338
1339 =item C<action_test_page>
1340
1341 =item C<action_part_picker_search>
1342
1343 =item C<action_part_picker_result>
1344
1345 =item C<action_show>
1346
1347 =back
1348
1349 =head1 FORM CHECKS
1350
1351 =over 2
1352
1353 =item C<check_form>
1354
1355 Calls some simple checks that test the submitted $::form for obvious errors.
1356 Return 1 if all the tests were successfull, 0 as soon as one test fails.
1357
1358 Errors from the failed tests are stored as ClientJS actions in $self->js. In
1359 some cases extra actions are taken, e.g. if the part description is missing the
1360 basic data tab is selected and the description input field is focussed.
1361
1362 =back
1363
1364 =over 4
1365
1366 =item C<form_check_part_description_exists>
1367
1368 =item C<form_check_assortment_items_exist>
1369
1370 =item C<form_check_assortment_items_unique>
1371
1372 =item C<form_check_assembly_items_exist>
1373
1374 =item C<form_check_partnumber_is_unique>
1375
1376 =back
1377
1378 =head1 HELPER FUNCTIONS
1379
1380 =over 4
1381
1382 =item C<parse_form>
1383
1384 When submitting the form for saving, parses the transmitted form. Expects the
1385 following data:
1386
1387  $::form->{part}
1388  $::form->{makemodels}
1389  $::form->{translations}
1390  $::form->{prices}
1391  $::form->{assemblies}
1392  $::form->{assortments}
1393
1394 CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
1395
1396 =item C<recalc_item_totals %params>
1397
1398 Helper function for calculating the total lastcost and sellprice for assemblies
1399 or assortments according to their items, which are parsed from the current
1400 $::form.
1401
1402 Is called whenever the qty of an item is changed or items are deleted.
1403
1404 Takes two params:
1405
1406 * part_type : 'assortment' or 'assembly' (mandatory)
1407
1408 * price_type: 'lastcost' or 'sellprice', default is 'sellprice'
1409
1410 Depending on the price_type the lastcost sum or sellprice sum is returned.
1411
1412 Doesn't work for recursive items.
1413
1414 =back
1415
1416 =head1 GET SET INITS
1417
1418 There are get_set_inits for
1419
1420 * assembly items
1421
1422 * assortment items
1423
1424 * makemodels
1425
1426 which parse $::form and automatically create an array of objects.
1427
1428 These inits are used during saving and each time a new element is added.
1429
1430 =over 4
1431
1432 =item C<init_makemodels>
1433
1434 Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
1435 $self->part->makemodels, ready to be saved.
1436
1437 Used for saving parts and adding new makemodel rows.
1438
1439 =item C<parse_add_items_to_objects PART_TYPE>
1440
1441 Parses the resulting form from either the part-picker submit or the multi-item
1442 submit, and creates an arrayref of assortment_item or assembly objects, that
1443 can be rendered via C<render_assortment_items_to_html> or
1444 C<render_assembly_items_to_html>.
1445
1446 Mandatory param: part_type: assortment or assembly (the resulting html will differ)
1447 Optional param: position (used for numbering and listrow class)
1448
1449 =item C<render_assortment_items_to_html ITEM_OBJECTS>
1450
1451 Takes an array_ref of assortment_items, and generates tables rows ready for
1452 adding to the assortment table.  Is used when a part is loaded, or whenever new
1453 assortment items are added.
1454
1455 =item C<parse_form_makemodels>
1456
1457 Makemodels can't just be overwritten, because of the field "lastupdate", that
1458 remembers when the lastcost for that vendor changed the last time.
1459
1460 So the original values are cloned and remembered, so we can compare if lastcost
1461 was changed in $::form, and keep or update lastupdate.
1462
1463 lastcost isn't updated until the first time it was saved with a value, until
1464 then it is empty.
1465
1466 Also a boolean "makemodel" needs to be written in parts, depending on whether
1467 makemodel entries exist or not.
1468
1469 We still need init_makemodels for when we open the part for editing.
1470
1471 =back
1472
1473 =head1 TODO
1474
1475 =over 4
1476
1477 =item *
1478
1479 It should be possible to jump to the edit page in a specific tab
1480
1481 =item *
1482
1483 Support callbacks, e.g. creating a new part from within an order, and jumping
1484 back to the order again afterwards.
1485
1486 =item *
1487
1488 Support units when adding assembly items or assortment items. Currently the
1489 default unit of the item is always used.
1490
1491 =item *
1492
1493 Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
1494 consists of other assemblies.
1495
1496 =back
1497
1498 =head1 AUTHOR
1499
1500 G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
1501
1502 =cut