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