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