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