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