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