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