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