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