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