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