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