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