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