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