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