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