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