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