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