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