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