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