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