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