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