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