1 package SL::Controller::DeliveryOrder;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Helper::Number qw(_format_number _parse_number);
8 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
9 use SL::Presenter::DeliveryOrder qw(delivery_order_status_line);
10 use SL::Locale::String qw(t8);
11 use SL::SessionFile::Random;
16 use SL::Util qw(trim);
24 use SL::DB::PartClassification;
25 use SL::DB::PartsGroup;
28 use SL::DB::RecordLink;
30 use SL::DB::Translation;
31 use SL::DB::TransferType;
33 use SL::Helper::CreatePDF qw(:all);
34 use SL::Helper::PrintOptions;
35 use SL::Helper::ShippedQty;
36 use SL::Helper::UserPreferences::DisplayPreferences;
37 use SL::Helper::UserPreferences::PositionsScrollbar;
38 use SL::Helper::UserPreferences::UpdatePositions;
40 use SL::Controller::Helper::GetModels;
41 use SL::Controller::DeliveryOrder::TypeData qw(:types);
43 use List::Util qw(first sum0);
44 use List::UtilsBy qw(sort_by uniq_by);
45 use List::MoreUtils qw(any none pairwise first_index);
46 use English qw(-no_match_vars);
51 use Rose::Object::MakeMethods::Generic
53 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
54 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ],
59 __PACKAGE__->run_before('check_auth',
60 except => [ qw(update_stock_information) ]);
62 __PACKAGE__->run_before('check_auth_for_edit',
63 except => [ qw(update_stock_information edit show_customer_vendor_details_dialog price_popup stock_in_out_dialog load_second_rows) ]);
65 __PACKAGE__->run_before('get_unalterable_data',
66 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
77 $self->order->transdate(DateTime->now_local());
78 $self->type_data->set_reqdate_by_type;
83 'delivery_order/form',
84 title => $self->get_title_for('add'),
85 %{$self->{template_args}}
89 sub action_add_from_order {
91 # this interfers with init_order
92 $self->{converted_from_oe_id} = delete $::form->{id};
94 $self->type_data->validate($::form->{type});
96 my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load;
98 $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type}));
103 # edit an existing order
111 # this is to edit an order from an unsaved order object
113 # set item ids to new fake id, to identify them as new items
114 foreach my $item (@{$self->order->items_sorted}) {
115 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
117 # trigger rendering values for second row as hidden, because they
118 # are loaded only on demand. So we need to keep the values from
120 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
125 'delivery_order/form',
126 title => $self->get_title_for('edit'),
127 %{$self->{template_args}}
131 # edit a collective order (consisting of one or more existing orders)
132 sub action_edit_collective {
136 my @multi_ids = map {
137 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
138 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
140 # fall back to add if no ids are given
141 if (scalar @multi_ids == 0) {
146 # fall back to save as new if only one id is given
147 if (scalar @multi_ids == 1) {
148 $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
149 $self->action_save_as_new();
153 # make new order from given orders
154 my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
155 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
156 $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
158 $self->action_edit();
165 my $errors = $self->delete();
167 if (scalar @{ $errors }) {
168 $self->js->flash('error', $_) foreach @{ $errors };
169 return $self->js->render();
172 flash_later('info', $self->type_data->text("delete"));
174 my @redirect_params = (
179 $self->redirect_to(@redirect_params);
186 my $errors = $self->save();
188 if (scalar @{ $errors }) {
189 $self->js->flash('error', $_) foreach @{ $errors };
190 return $self->js->render();
193 flash_later('info', $self->type_data->text("saved"));
195 my @redirect_params = (
198 id => $self->order->id,
201 $self->redirect_to(@redirect_params);
204 # save the order as new document an open it for edit
205 sub action_save_as_new {
208 my $order = $self->order;
211 $self->js->flash('error', t8('This object has not been saved yet.'));
212 return $self->js->render();
215 # load order from db to check if values changed
216 my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
219 # Lets assign a new number if the user hasn't changed the previous one.
220 # If it has been changed manually then use it as-is.
221 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
223 : trim($order->number);
225 # Clear transdate unless changed
226 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
227 ? DateTime->today_local
230 # Set new reqdate unless changed if it is enabled in client config
231 $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
234 $new_attrs{employee} = SL::DB::Manager::Employee->current;
236 # Create new record from current one
237 $self->order(SL::DB::DeliveryOrder->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
239 # no linked records on save as new
240 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
243 $self->action_save();
248 # This is called if "print" is pressed in the print dialog.
249 # If PDF creation was requested and succeeded, the pdf is offered for download
250 # via send_file (which uses ajax in this case).
254 my $errors = $self->save();
256 if (scalar @{ $errors }) {
257 $self->js->flash('error', $_) foreach @{ $errors };
258 return $self->js->render();
261 $self->js_reset_order_and_item_ids_after_save;
263 my $format = $::form->{print_options}->{format};
264 my $media = $::form->{print_options}->{media};
265 my $formname = $::form->{print_options}->{formname};
266 my $copies = $::form->{print_options}->{copies};
267 my $groupitems = $::form->{print_options}->{groupitems};
268 my $printer_id = $::form->{print_options}->{printer_id};
270 # only pdf and opendocument by now
271 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
272 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
275 # only screen or printer by now
276 if (none { $media eq $_ } qw(screen printer)) {
277 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
280 # create a form for generate_attachment_filename
281 my $form = Form->new;
282 $form->{$self->nr_key()} = $self->order->number;
283 $form->{type} = $self->type;
284 $form->{format} = $format;
285 $form->{formname} = $formname;
286 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
287 my $pdf_filename = $form->generate_attachment_filename();
290 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
291 formname => $formname,
292 language => $self->order->language,
293 printer_id => $printer_id,
294 groupitems => $groupitems });
295 if (scalar @errors) {
296 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
299 if ($media eq 'screen') {
301 $self->js->flash('info', t8('The PDF has been created'));
304 type => SL::MIME->mime_type_from_ext($pdf_filename),
305 name => $pdf_filename,
309 } elsif ($media eq 'printer') {
311 my $printer_id = $::form->{print_options}->{printer_id};
312 SL::DB::Printer->new(id => $printer_id)->load->print_document(
317 $self->js->flash('info', t8('The PDF has been printed'));
320 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
321 if (scalar @warnings) {
322 $self->js->flash('warning', $_) for @warnings;
325 $self->save_history('PRINTED');
328 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
331 sub action_preview_pdf {
334 my $errors = $self->save();
335 if (scalar @{ $errors }) {
336 $self->js->flash('error', $_) foreach @{ $errors };
337 return $self->js->render();
340 $self->js_reset_order_and_item_ids_after_save;
343 my $media = 'screen';
344 my $formname = $self->type;
347 # create a form for generate_attachment_filename
348 my $form = Form->new;
349 $form->{$self->nr_key()} = $self->order->number;
350 $form->{type} = $self->type;
351 $form->{format} = $format;
352 $form->{formname} = $formname;
353 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
354 my $pdf_filename = $form->generate_attachment_filename();
357 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
358 formname => $formname,
359 language => $self->order->language,
361 if (scalar @errors) {
362 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
364 $self->save_history('PREVIEWED');
365 $self->js->flash('info', t8('The PDF has been previewed'));
369 type => SL::MIME->mime_type_from_ext($pdf_filename),
370 name => $pdf_filename,
375 # open the email dialog
376 sub action_save_and_show_email_dialog {
379 my $errors = $self->save();
381 if (scalar @{ $errors }) {
382 $self->js->flash('error', $_) foreach @{ $errors };
383 return $self->js->render();
386 my $cv_method = $self->cv;
388 if (!$self->order->$cv_method) {
389 return $self->js->flash('error', $self->cv eq 'customer' ? t8('Cannot send E-mail without customer given') : t8('Cannot send E-mail without vendor given'))
394 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
395 $email_form->{to} ||= $self->order->$cv_method->email;
396 $email_form->{cc} = $self->order->$cv_method->cc;
397 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
398 # Todo: get addresses from shipto, if any
400 my $form = Form->new;
401 $form->{$self->nr_key()} = $self->order->number;
402 $form->{cusordnumber} = $self->order->cusordnumber;
403 $form->{formname} = $self->type;
404 $form->{type} = $self->type;
405 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
406 $form->{language_id} = $self->order->language->id if $self->order->language;
407 $form->{format} = 'pdf';
408 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
410 $email_form->{subject} = $form->generate_email_subject();
411 $email_form->{attachment_filename} = $form->generate_attachment_filename();
412 $email_form->{message} = $form->generate_email_body();
413 $email_form->{js_send_function} = 'kivi.DeliveryOrder.send_email()';
415 my %files = $self->get_files_for_email_dialog();
416 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
417 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
418 email_form => $email_form,
419 show_bcc => $::auth->assert('email_bcc', 'may fail'),
421 is_customer => $self->type_data->is_customer,
422 ALL_EMPLOYEES => $self->{all_employees},
426 ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html)
433 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
434 sub action_send_email {
437 my $errors = $self->save();
439 if (scalar @{ $errors }) {
440 $self->js->run('kivi.DeliveryOrder.close_email_dialog');
441 $self->js->flash('error', $_) foreach @{ $errors };
442 return $self->js->render();
445 $self->js_reset_order_and_item_ids_after_save;
447 my $email_form = delete $::form->{email_form};
448 my %field_names = (to => 'email');
450 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
452 # for Form::cleanup which may be called in Form::send_email
453 $::form->{cwd} = getcwd();
454 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
456 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
457 $::form->{media} = 'email';
459 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
461 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
462 format => $::form->{print_options}->{format},
463 formname => $::form->{print_options}->{formname},
464 language => $self->order->language,
465 printer_id => $::form->{print_options}->{printer_id},
466 groupitems => $::form->{print_options}->{groupitems}});
467 if (scalar @errors) {
468 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
471 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
472 if (scalar @warnings) {
473 flash_later('warning', $_) for @warnings;
476 my $sfile = SL::SessionFile::Random->new(mode => "w");
477 $sfile->fh->print($pdf);
480 $::form->{tmpfile} = $sfile->file_name;
481 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
484 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
485 $::form->send_email(\%::myconfig, 'pdf');
487 # internal notes unless no email journal
488 unless ($::instance_conf->get_email_journal) {
490 my $intnotes = $self->order->intnotes;
491 $intnotes .= "\n\n" if $self->order->intnotes;
492 $intnotes .= t8('[email]') . "\n";
493 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
494 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
495 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
496 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
497 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
498 $intnotes .= t8('Message') . ": " . $::form->{message};
500 $self->order->update_attributes(intnotes => $intnotes);
503 $self->save_history('MAILED');
505 flash_later('info', t8('The email has been sent.'));
507 my @redirect_params = (
510 id => $self->order->id,
513 $self->redirect_to(@redirect_params);
516 # save the order and redirect to the frontend subroutine for a new
518 sub action_save_and_delivery_order {
521 $self->save_and_redirect_to(
522 controller => 'oe.pl',
523 action => 'oe_delivery_order_from_order',
527 # save the order and redirect to the frontend subroutine for a new
529 sub action_save_and_invoice {
532 $self->save_and_redirect_to(
533 controller => 'oe.pl',
534 action => 'oe_invoice_from_order',
538 # workflow from sales order to sales quotation
539 sub action_sales_quotation {
540 $_[0]->workflow_sales_or_request_for_quotation();
543 # workflow from sales order to sales quotation
544 sub action_request_for_quotation {
545 $_[0]->workflow_sales_or_request_for_quotation();
548 # workflow from sales quotation to sales order
549 sub action_sales_order {
550 $_[0]->workflow_sales_or_purchase_order();
553 # workflow from rfq to purchase order
554 sub action_purchase_order {
555 $_[0]->workflow_sales_or_purchase_order();
558 # workflow from purchase order to ap transaction
559 sub action_save_and_ap_transaction {
562 $self->save_and_redirect_to(
563 controller => 'ap.pl',
564 action => 'add_from_purchase_order',
568 # set form elements in respect to a changed customer or vendor
570 # This action is called on an change of the customer/vendor picker.
571 sub action_customer_vendor_changed {
574 setup_order_from_cv($self->order);
576 my $cv_method = $self->cv;
578 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
579 $self->js->show('#cp_row');
581 $self->js->hide('#cp_row');
584 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
585 $self->js->show('#shipto_selection');
587 $self->js->hide('#shipto_selection');
590 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
593 ->replaceWith('#order_cp_id', $self->build_contact_select)
594 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
595 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
596 ->replaceWith('#business_info_row', $self->build_business_info_row)
597 ->val( '#order_taxzone_id', $self->order->taxzone_id)
598 ->val( '#order_taxincluded', $self->order->taxincluded)
599 ->val( '#order_currency_id', $self->order->currency_id)
600 ->val( '#order_payment_id', $self->order->payment_id)
601 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
602 ->val( '#order_intnotes', $self->order->intnotes)
603 ->val( '#order_language_id', $self->order->$cv_method->language_id)
604 ->focus( '#order_' . $self->cv . '_id')
605 ->run('kivi.DeliveryOrder.update_exchangerate');
607 $self->js_redisplay_cvpartnumbers;
611 # open the dialog for customer/vendor details
612 sub action_show_customer_vendor_details_dialog {
615 my $is_customer = 'customer' eq $::form->{vc};
618 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
620 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
623 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
624 $details{discount_as_percent} = $cv->discount_as_percent;
625 $details{creditlimt} = $cv->creditlimit_as_number;
626 $details{business} = $cv->business->description if $cv->business;
627 $details{language} = $cv->language_obj->description if $cv->language_obj;
628 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
629 $details{payment_terms} = $cv->payment->description if $cv->payment;
630 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
632 foreach my $entry (@{ $cv->shipto }) {
633 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
635 foreach my $entry (@{ $cv->contacts }) {
636 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
639 $_[0]->render('common/show_vc_details', { layout => 0 },
640 is_customer => $is_customer,
645 # called if a unit in an existing item row is changed
646 sub action_unit_changed {
649 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
650 my $item = $self->order->items_sorted->[$idx];
652 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
653 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
656 ->run('kivi.DeliveryOrder.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
657 $self->js_redisplay_line_values;
661 # add an item row for a new item entered in the input row
662 sub action_add_item {
665 delete $::form->{add_item}->{create_part_type};
667 my $form_attr = $::form->{add_item};
669 return unless $form_attr->{parts_id};
671 my $item = new_item($self->order, $form_attr);
673 $self->order->add_items($item);
675 $self->get_item_cvpartnumber($item);
677 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
678 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
682 in_out => $self->type_data->transfer,
685 if ($::form->{insert_before_item_id}) {
687 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
690 ->append('#row_table_id', $row_as_html);
693 if ( $item->part->is_assortment ) {
694 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
695 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
696 my $attr = { parts_id => $assortment_item->parts_id,
697 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
698 unit => $assortment_item->unit,
699 description => $assortment_item->part->description,
701 my $item = new_item($self->order, $attr);
703 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
704 $item->discount(1) unless $assortment_item->charge;
706 $self->order->add_items( $item );
707 $self->get_item_cvpartnumber($item);
708 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
709 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
714 if ($::form->{insert_before_item_id}) {
716 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
719 ->append('#row_table_id', $row_as_html);
725 ->val('.add_item_input', '')
726 ->run('kivi.DeliveryOrder.init_row_handlers')
727 ->run('kivi.DeliveryOrder.renumber_positions')
728 ->focus('#add_item_parts_id_name');
730 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
735 # add item rows for multiple items at once
736 sub action_add_multi_items {
739 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
740 return $self->js->render() unless scalar @form_attr;
743 foreach my $attr (@form_attr) {
744 my $item = new_item($self->order, $attr);
746 if ( $item->part->is_assortment ) {
747 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
748 my $attr = { parts_id => $assortment_item->parts_id,
749 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
750 unit => $assortment_item->unit,
751 description => $assortment_item->part->description,
753 my $item = new_item($self->order, $attr);
755 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
756 $item->discount(1) unless $assortment_item->charge;
761 $self->order->add_items(@items);
763 foreach my $item (@items) {
764 $self->get_item_cvpartnumber($item);
765 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
766 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
770 in_out => $self->type_data->transfer,
773 if ($::form->{insert_before_item_id}) {
775 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
778 ->append('#row_table_id', $row_as_html);
783 ->run('kivi.Part.close_picker_dialogs')
784 ->run('kivi.DeliveryOrder.init_row_handlers')
785 ->run('kivi.DeliveryOrder.renumber_positions')
786 ->focus('#add_item_parts_id_name');
788 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
793 sub action_update_exchangerate {
797 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
798 currency_name => $self->order->currency->name,
801 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
804 # redisplay item rows if they are sorted by an attribute
805 sub action_reorder_items {
809 partnumber => sub { $_[0]->part->partnumber },
810 description => sub { $_[0]->description },
811 qty => sub { $_[0]->qty },
812 sellprice => sub { $_[0]->sellprice },
813 discount => sub { $_[0]->discount },
814 cvpartnumber => sub { $_[0]->{cvpartnumber} },
817 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
819 my $method = $sort_keys{$::form->{order_by}};
820 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
821 if ($::form->{sort_dir}) {
822 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
823 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
825 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
828 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
829 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
831 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
835 ->run('kivi.DeliveryOrder.redisplay_items', \@to_sort)
839 # show the popup to choose a price/discount source
840 sub action_price_popup {
843 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
844 my $item = $self->order->items_sorted->[$idx];
846 $self->render_price_dialog($item);
849 # save the order in a session variable and redirect to the part controller
850 sub action_create_part {
853 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
855 my $callback = $self->url_for(
856 action => 'return_from_create_part',
857 type => $self->type, # type is needed for check_auth on return
858 previousform => $previousform,
861 flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.'));
863 my @redirect_params = (
864 controller => 'Part',
866 part_type => $::form->{add_item}->{create_part_type},
867 callback => $callback,
871 $self->redirect_to(@redirect_params);
874 sub action_return_from_create_part {
877 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
879 $::auth->restore_form_from_session(delete $::form->{previousform});
881 # set item ids to new fake id, to identify them as new items
882 foreach my $item (@{$self->order->items_sorted}) {
883 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
886 $self->get_unalterable_data();
889 # trigger rendering values for second row/longdescription as hidden,
890 # because they are loaded only on demand. So we need to keep the values
892 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
893 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
896 'delivery_order/form',
897 title => $self->get_title_for('edit'),
898 %{$self->{template_args}}
903 sub action_stock_in_out_dialog {
906 my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
907 my $unit = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit";
908 my $stock = $::form->{stock};
909 my $row = $::form->{row};
910 my $item_id = $::form->{item_id};
911 my $qty = _parse_number($::form->{qty_as_number});
913 my $inout = $self->type_data->transfer;
915 my @contents = DO->get_item_availability(parts_id => $part->id);
916 my $stock_info = DO->unpack_stock_information(packed => $stock);
918 $self->merge_stock_data($stock_info, \@contents, $part, $unit);
920 $self->render("delivery_order/stock_dialog", { layout => 0 },
921 WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
924 do_unit => $unit->unit,
925 delivered => $self->order->delivered,
931 sub action_update_stock_information {
934 my $stock_info = $::form->{stock_info};
935 my $unit = $::form->{unit};
936 my $yaml = SL::YAML::Dump($stock_info);
937 my $stock_qty = $self->calculate_stock_in_out_from_stock_info($unit, $stock_info);
941 stock_qty => $stock_qty,
943 $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 });
946 sub merge_stock_data {
947 my ($self, $stock_info, $contents, $part, $unit) = @_;
948 # TODO rewrite to mapping
950 if (!$self->order->delivered) {
951 for my $row (@$contents) {
952 # row here is in parts units. stock is in item units
953 $row->{available_qty} = _format_number($part->unit_obj->convert_to($row->{qty}, $unit));
955 for my $sinfo (@{ $stock_info }) {
956 next if $row->{bin_id} != $sinfo->{bin_id} ||
957 $row->{warehouse_id} != $sinfo->{warehouse_id} ||
958 $row->{chargenumber} ne $sinfo->{chargenumber} ||
959 $row->{bestbefore} ne $sinfo->{bestbefore};
961 $row->{"stock_$_"} = $sinfo->{$_}
962 for qw(qty unit error delivery_order_items_stock_id);
967 for my $sinfo (@{ $stock_info }) {
968 my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
969 $sinfo->{warehousedescription} = $bin->warehouse->description;
970 $sinfo->{bindescription} = $bin->description;
971 map { $sinfo->{"stock_$_"} = $sinfo->{$_} } qw(qty unit);
976 # load the second row for one or more items
978 # This action gets the html code for all items second rows by rendering a template for
979 # the second row and sets the html code via client js.
980 sub action_load_second_rows {
983 foreach my $item_id (@{ $::form->{item_ids} }) {
984 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
985 my $item = $self->order->items_sorted->[$idx];
987 $self->js_load_second_row($item, $item_id, 0);
990 $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
995 # update description, notes and sellprice from master data
996 sub action_update_row_from_master_data {
999 foreach my $item_id (@{ $::form->{item_ids} }) {
1000 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1001 my $item = $self->order->items_sorted->[$idx];
1002 my $texts = get_part_texts($item->part, $self->order->language_id);
1004 $item->description($texts->{description});
1005 $item->longdescription($texts->{longdescription});
1007 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1010 if ($item->part->is_assortment) {
1011 # add assortment items with price 0, as the components carry the price
1012 $price_src = $price_source->price_from_source("");
1013 $price_src->price(0);
1015 $price_src = $price_source->best_price
1016 ? $price_source->best_price
1017 : $price_source->price_from_source("");
1018 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1019 $price_src->price(0) if !$price_source->best_price;
1023 $item->sellprice($price_src->price);
1024 $item->active_price_source($price_src);
1027 ->run('kivi.DeliveryOrder.update_sellprice', $item_id, $item->sellprice_as_number)
1028 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1029 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1030 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1032 if ($self->search_cvpartnumber) {
1033 $self->get_item_cvpartnumber($item);
1034 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1038 $self->js_redisplay_line_values;
1040 $self->js->render();
1043 sub action_transfer_stock {
1046 if ($self->order->delivered) {
1047 return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
1050 my $inout = $self->type_data->properties('transfer');
1052 my $errors = $self->save;
1055 $self->js->flash('error', $_) for @$errors;
1056 return $self->js->render;
1059 my $order = $self->order;
1061 # TODO move to type data
1062 my $trans_type = $inout eq 'in'
1063 ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
1064 : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped");
1066 my @transfer_requests;
1068 for my $item (@{ $order->items_sorted }) {
1069 for my $stock (@{ $item->delivery_order_stock_entries }) {
1070 my $transfer = SL::DB::Inventory->new_from($stock);
1071 $transfer->trans_type($trans_type);
1072 $transfer->qty($transfer->qty * -1) if $inout eq 'out';
1074 push @transfer_requests, $transfer if defined $transfer->qty && $transfer->qty != 0;
1078 if (!@transfer_requests) {
1079 return $self->js->flash("error", t8("No stock to transfer"))->render;
1082 SL::DB->client->with_transaction(sub {
1083 $_->save for @transfer_requests;
1084 $self->order->update_attributes(delivered => 1);
1088 ->flash("info", t8("Stock transfered"))
1089 ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred'))
1090 ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred'))
1091 ->run('kivi.ActionBar.setDisabled', '#delete_action', t8('The parts for this order have already been transferred'))
1092 ->replaceWith('#data-status-line', delivery_order_status_line($self->order))
1097 sub js_load_second_row {
1098 my ($self, $item, $item_id, $do_parse) = @_;
1101 # Parse values from form (they are formated while rendering (template)).
1102 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1103 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1104 foreach my $var (@{ $item->cvars_by_config }) {
1105 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1107 $item->parse_custom_variable_values;
1110 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1113 ->html('#second_row_' . $item_id, $row_as_html)
1114 ->data('#second_row_' . $item_id, 'loaded', 1);
1117 sub js_redisplay_line_values {
1120 my $is_sales = $self->order->is_sales;
1122 # sales orders with margins
1127 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1128 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1129 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1130 ]} @{ $self->order->items_sorted };
1134 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1135 ]} @{ $self->order->items_sorted };
1139 ->run('kivi.DeliveryOrder.redisplay_line_values', $is_sales, \@data);
1142 sub js_redisplay_cvpartnumbers {
1145 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1147 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1150 ->run('kivi.DeliveryOrder.redisplay_cvpartnumbers', \@data);
1153 sub js_reset_order_and_item_ids_after_save {
1157 ->val('#id', $self->order->id)
1158 ->val('#converted_from_oe_id', '')
1159 ->val('#order_' . $self->nr_key(), $self->order->number);
1162 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1163 next if !$self->order->items_sorted->[$idx]->id;
1164 next if $form_item_id !~ m{^new};
1166 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1167 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1168 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1172 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1182 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1183 die "Not a valid type for delivery order";
1186 $self->type($::form->{type});
1192 return $self->type_data->customervendor;
1195 sub init_search_cvpartnumber {
1198 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1199 my $search_cvpartnumber;
1200 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1201 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1203 return $search_cvpartnumber;
1206 sub init_show_update_button {
1209 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1220 sub init_all_price_factors {
1221 SL::DB::Manager::PriceFactor->get_all;
1224 sub init_part_picker_classification_ids {
1227 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1233 $::auth->assert($self->type_data->access('view') || 'DOES_NOT_EXIST');
1236 sub check_auth_for_edit {
1239 $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST');
1242 # build the selection box for contacts
1244 # Needed, if customer/vendor changed.
1245 sub build_contact_select {
1248 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1249 value_key => 'cp_id',
1250 title_key => 'full_name_dep',
1251 default => $self->order->cp_id,
1253 style => 'width: 300px',
1257 # build the selection box for shiptos
1259 # Needed, if customer/vendor changed.
1260 sub build_shipto_select {
1263 select_tag('order.shipto_id',
1264 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1265 value_key => 'shipto_id',
1266 title_key => 'displayable_id',
1267 default => $self->order->shipto_id,
1269 style => 'width: 300px',
1273 # build the inputs for the cusom shipto dialog
1275 # Needed, if customer/vendor changed.
1276 sub build_shipto_inputs {
1279 my $content = $self->p->render('common/_ship_to_dialog',
1280 vc_obj => $self->order->customervendor,
1281 cs_obj => $self->order->custom_shipto,
1282 cvars => $self->order->custom_shipto->cvars_by_config,
1283 id_selector => '#order_shipto_id');
1285 div_tag($content, id => 'shipto_inputs');
1288 # render the info line for business
1290 # Needed, if customer/vendor changed.
1291 sub build_business_info_row
1293 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1300 return if !$::form->{id};
1302 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1304 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1305 # You need a custom shipto object to call cvars_by_config to get the cvars.
1306 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1308 $self->prepare_stock_info($_) for $self->order->items;
1310 return $self->order;
1313 # load or create a new order object
1315 # And assign changes from the form to this object.
1316 # If the order is loaded from db, check if items are deleted in the form,
1317 # remove them form the object and collect them for removing from db on saving.
1318 # Then create/update items from form (via make_item) and add them.
1322 # add_items adds items to an order with no items for saving, but they cannot
1323 # be retrieved via items until the order is saved. Adding empty items to new
1324 # order here solves this problem.
1326 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1327 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1329 my $cv_id_method = $self->cv . '_id';
1330 if (!$::form->{id} && $::form->{$cv_id_method}) {
1331 $order->$cv_id_method($::form->{$cv_id_method});
1332 setup_order_from_cv($order);
1335 my $form_orderitems = delete $::form->{order}->{orderitems};
1337 $order->assign_attributes(%{$::form->{order}});
1339 $self->setup_custom_shipto_from_form($order, $::form);
1341 # remove deleted items
1342 $self->item_ids_to_delete([]);
1343 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1344 my $item = $order->orderitems->[$idx];
1345 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1346 splice @{$order->orderitems}, $idx, 1;
1347 push @{$self->item_ids_to_delete}, $item->id;
1353 foreach my $form_attr (@{$form_orderitems}) {
1354 my $item = make_item($order, $form_attr);
1355 $item->position($pos);
1360 $self->prepare_stock_info($_) for $order->items, @items;
1362 $order->add_items(grep {!$_->id} @items);
1367 # create or update items from form
1369 # Make item objects from form values. For items already existing read from db.
1370 # Create a new item else. And assign attributes.
1372 my ($record, $attr) = @_;
1375 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1377 my $is_new = !$item;
1379 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1380 # they cannot be retrieved via custom_variables until the order/orderitem is
1381 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1382 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1385 if (my $stock_info = delete $attr->{stock_info}) {
1386 my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
1389 for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
1390 # lookup existing or make new
1391 my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
1392 // SL::DB::DeliveryOrderItemsStock->new;
1395 $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
1396 $obj->bestbefore_as_date($line->{bestfbefore})
1397 if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
1398 push @save, $obj if $obj->qty;
1401 $item->delivery_order_stock_entries(@save);
1404 $item->assign_attributes(%$attr);
1407 my $texts = get_part_texts($item->part, $record->language_id);
1408 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1409 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1410 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1418 # This is used to add one item
1420 my ($record, $attr) = @_;
1422 my $item = SL::DB::DeliveryOrderItem->new;
1424 # Remove attributes where the user left or set the inputs empty.
1425 # So these attributes will be undefined and we can distinguish them
1426 # from zero later on.
1427 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1428 delete $attr->{$_} if $attr->{$_} eq '';
1431 $item->assign_attributes(%$attr);
1433 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1434 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1436 $item->unit($part->unit) if !$item->unit;
1439 if ( $part->is_assortment ) {
1440 # add assortment items with price 0, as the components carry the price
1441 $price_src = $price_source->price_from_source("");
1442 $price_src->price(0);
1443 } elsif (defined $item->sellprice) {
1444 $price_src = $price_source->price_from_source("");
1445 $price_src->price($item->sellprice);
1447 $price_src = $price_source->best_price
1448 ? $price_source->best_price
1449 : $price_source->price_from_source("");
1450 $price_src->price(0) if !$price_source->best_price;
1454 if (defined $item->discount) {
1455 $discount_src = $price_source->discount_from_source("");
1456 $discount_src->discount($item->discount);
1458 $discount_src = $price_source->best_discount
1459 ? $price_source->best_discount
1460 : $price_source->discount_from_source("");
1461 $discount_src->discount(0) if !$price_source->best_discount;
1465 $new_attr{part} = $part;
1466 $new_attr{description} = $part->description if ! $item->description;
1467 $new_attr{qty} = 1.0 if ! $item->qty;
1468 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1469 $new_attr{sellprice} = $price_src->price;
1470 $new_attr{discount} = $discount_src->discount;
1471 $new_attr{active_price_source} = $price_src;
1472 $new_attr{active_discount_source} = $discount_src;
1473 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1474 $new_attr{project_id} = $record->globalproject_id;
1475 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1477 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1478 # they cannot be retrieved via custom_variables until the order/orderitem is
1479 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1480 $new_attr{custom_variables} = [];
1482 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1484 $item->assign_attributes(%new_attr, %{ $texts });
1489 sub prepare_stock_info {
1490 my ($self, $item) = @_;
1492 $item->{stock_info} = SL::YAML::Dump([
1494 delivery_order_items_stock_id => $_->id,
1496 warehouse_id => $_->warehouse_id,
1497 bin_id => $_->bin_id,
1498 chargenumber => $_->chargenumber,
1500 }, $item->delivery_order_stock_entries
1504 sub setup_order_from_cv {
1507 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1509 $order->intnotes($order->customervendor->notes);
1511 if ($order->is_sales) {
1512 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1513 $order->taxincluded(defined($order->customer->taxincluded_checked)
1514 ? $order->customer->taxincluded_checked
1515 : $::myconfig{taxincluded_checked});
1520 # setup custom shipto from form
1522 # The dialog returns form variables starting with 'shipto' and cvars starting
1523 # with 'shiptocvar_'.
1524 # Mark it to be deleted if a shipto from master data is selected
1525 # (i.e. order has a shipto).
1526 # Else, update or create a new custom shipto. If the fields are empty, it
1527 # will not be saved on save.
1528 sub setup_custom_shipto_from_form {
1529 my ($self, $order, $form) = @_;
1531 if ($order->shipto) {
1532 $self->is_custom_shipto_to_delete(1);
1534 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1536 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1537 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1539 $custom_shipto->assign_attributes(%$shipto_attrs);
1540 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1544 # get data for saving, printing, ..., that is not changed in the form
1546 # Only cvars for now.
1547 sub get_unalterable_data {
1550 foreach my $item (@{ $self->order->items }) {
1551 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1552 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1553 foreach my $var (@{ $item->cvars_by_config }) {
1554 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1556 $item->parse_custom_variable_values;
1562 # And remove related files in the spool directory
1567 my $db = $self->order->db;
1569 $db->with_transaction(
1571 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1572 $self->order->delete;
1573 my $spool = $::lx_office_conf{paths}->{spool};
1574 unlink map { "$spool/$_" } @spoolfiles if $spool;
1576 $self->save_history('DELETED');
1579 }) || push(@{$errors}, $db->error);
1586 # And delete items that are deleted in the form.
1591 my $db = $self->order->db;
1593 $db->with_transaction(sub {
1594 # delete custom shipto if it is to be deleted or if it is empty
1595 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1596 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1597 $self->order->custom_shipto(undef);
1600 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1601 $self->order->save(cascade => 1);
1604 if ($::form->{converted_from_oe_id}) {
1605 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1606 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1607 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1608 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
1609 $src->link_to_record($self->order);
1611 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1613 foreach (@{ $self->order->items_sorted }) {
1614 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1616 SL::DB::RecordLink->new(from_table => 'orderitems',
1617 from_id => $from_id,
1618 to_table => 'orderitems',
1626 $self->save_history('SAVED');
1629 }) || push(@{$errors}, $db->error);
1634 sub workflow_sales_or_request_for_quotation {
1638 my $errors = $self->save();
1640 if (scalar @{ $errors }) {
1641 $self->js->flash('error', $_) for @{ $errors };
1642 return $self->js->render();
1645 my $destination_type = $self->type_data->workflow("to_quotation_type");
1647 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1648 $self->{converted_from_oe_id} = delete $::form->{id};
1650 # set item ids to new fake id, to identify them as new items
1651 foreach my $item (@{$self->order->items_sorted}) {
1652 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1656 $::form->{type} = $destination_type;
1657 $self->type($self->init_type);
1658 $self->cv ($self->init_cv);
1661 $self->get_unalterable_data();
1662 $self->pre_render();
1664 # trigger rendering values for second row as hidden, because they
1665 # are loaded only on demand. So we need to keep the values from the
1667 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1670 'delivery_order/form',
1671 title => $self->get_title_for('edit'),
1672 %{$self->{template_args}}
1676 sub workflow_sales_or_purchase_order {
1680 my $errors = $self->save();
1682 if (scalar @{ $errors }) {
1683 $self->js->flash('error', $_) foreach @{ $errors };
1684 return $self->js->render();
1687 my $destination_type = $self->type_data->workflow("to_order_type");
1689 # check for direct delivery
1690 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1692 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1693 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1696 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1697 $self->{converted_from_oe_id} = delete $::form->{id};
1699 # set item ids to new fake id, to identify them as new items
1700 foreach my $item (@{$self->order->items_sorted}) {
1701 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1704 if ($self->type_data->workflow("to_order_copy_shipto")) {
1705 if ($::form->{use_shipto}) {
1706 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1708 # remove any custom shipto if not wanted
1709 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1714 $::form->{type} = $destination_type;
1715 $self->type($self->init_type);
1716 $self->cv ($self->init_cv);
1719 $self->get_unalterable_data();
1720 $self->pre_render();
1722 # trigger rendering values for second row as hidden, because they
1723 # are loaded only on demand. So we need to keep the values from the
1725 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1728 'delivery_order/form',
1729 title => $self->get_title_for('edit'),
1730 %{$self->{template_args}}
1737 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1738 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1739 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1740 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted( query => [ or => [ obsolete => 0, id => $self->order->language_id ] ] );
1741 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1744 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1747 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1749 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1750 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1751 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1752 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1754 my $print_form = Form->new('');
1755 $print_form->{type} = $self->type;
1756 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1757 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1758 form => $print_form,
1759 options => {dialog_name_prefix => 'print_options.',
1763 no_opendocument => 0,
1767 foreach my $item (@{$self->order->orderitems}) {
1768 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1769 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1770 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1773 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1774 my $webdav = SL::Webdav->new(
1775 type => $self->type,
1776 number => $self->order->number,
1778 my @all_objects = $webdav->get_all_objects;
1779 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1781 link => File::Spec->catfile($_->full_filedescriptor),
1785 $self->{template_args}{in_out} = $self->type_data->transfer;
1786 $self->{template_args}{longdescription_dialog_size_percentage} = SL::Helper::UserPreferences::DisplayPreferences->new()->get_longdescription_dialog_size_percentage();
1788 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1790 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1791 calculate_qty kivi.Validator follow_up show_history);
1792 $self->setup_edit_action_bar;
1795 sub setup_edit_action_bar {
1796 my ($self, %params) = @_;
1798 my $deletion_allowed = $self->type_data->show_menu("delete");
1799 my $may_edit_create = $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST', 1);
1801 for my $bar ($::request->layout->get('actionbar')) {
1806 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1807 $::instance_conf->get_order_warn_no_deliverydate,
1809 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1813 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1814 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1815 : $self->type eq 'supplier_delivery_order' ? t8('Need a workflow for Supplier Delivery Order')
1816 : !$self->order->id ? t8('This object has not been saved yet.')
1819 ], # end of combobox "Save"
1826 t8('Save and Quotation'),
1827 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1828 only_if => $self->type_data->show_menu("save_and_quotation"),
1829 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1833 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1834 only_if => $self->type_data->show_menu("save_and_rfq"),
1835 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1838 t8('Save and Sales Order'),
1839 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1840 only_if => $self->type_data->show_menu("save_and_sales_order"),
1841 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1844 t8('Save and Purchase Order'),
1845 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1846 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1847 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1850 t8('Save and Delivery Order'),
1851 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1852 $::instance_conf->get_order_warn_no_deliverydate,
1854 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1855 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1858 t8('Save and Invoice'),
1859 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1860 only_if => $self->type_data->show_menu("save_and_invoice"),
1861 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1864 t8('Save and AP Transaction'),
1865 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1866 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1867 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1870 ], # end of combobox "Workflow"
1877 t8('Save and preview PDF'),
1878 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1879 $::instance_conf->get_order_warn_no_deliverydate,
1881 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1884 t8('Save and print'),
1885 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1886 $::instance_conf->get_order_warn_no_deliverydate,
1888 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1891 t8('Save and E-mail'),
1892 id => 'save_and_email_action',
1893 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1894 $::instance_conf->get_order_warn_no_deliverydate,
1896 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1897 : !$self->order->id ? t8('This object has not been saved yet.')
1901 t8('Download attachments of all parts'),
1902 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1903 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1904 : !$self->order->id ? t8('This object has not been saved yet.')
1906 only_if => $::instance_conf->get_doc_storage,
1908 ], # end of combobox "Export"
1912 id => 'delete_action',
1913 call => [ 'kivi.DeliveryOrder.delete_order' ],
1914 confirm => $::locale->text('Do you really want to delete this object?'),
1915 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1916 : !$self->order->id ? t8('This object has not been saved yet.')
1917 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1919 only_if => $self->type_data->show_menu("delete"),
1925 id => 'transfer_out_action',
1926 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1927 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1928 : !$self->order->id ? t8('This object has not been saved yet.')
1929 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1931 only_if => $self->type_data->properties('transfer') eq 'out',
1932 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1936 id => 'transfer_in_action',
1937 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1938 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1939 : !$self->order->id ? t8('This object has not been saved yet.')
1940 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1942 only_if => $self->type_data->properties('transfer') eq 'in',
1943 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1953 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1954 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1955 only_if => $::auth->assert('productivity', 1),
1959 call => [ 'set_history_window', $self->order->id, 'id' ],
1960 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1962 ], # end of combobox "more"
1968 my ($order, $pdf_ref, $params) = @_;
1972 my $print_form = Form->new('');
1973 $print_form->{type} = $order->type;
1974 $print_form->{formname} = $params->{formname} || $order->type;
1975 $print_form->{format} = $params->{format} || 'pdf';
1976 $print_form->{media} = $params->{media} || 'file';
1977 $print_form->{groupitems} = $params->{groupitems};
1978 $print_form->{printer_id} = $params->{printer_id};
1979 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1981 $order->language($params->{language});
1982 $order->flatten_to_form($print_form, format_amounts => 1);
1986 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1987 $template_ext = 'odt';
1988 $template_type = 'OpenDocument';
1991 # search for the template
1992 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1993 name => $print_form->{formname},
1994 extension => $template_ext,
1995 email => $print_form->{media} eq 'email',
1996 language => $params->{language},
1997 printer_id => $print_form->{printer_id},
2000 if (!defined $template_file) {
2001 push @errors, $::locale->text('Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', join ', ', map { "'$_'"} @template_files);
2004 return @errors if scalar @errors;
2006 $print_form->throw_on_error(sub {
2008 $print_form->prepare_for_printing;
2010 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
2011 format => $print_form->{format},
2012 template_type => $template_type,
2013 template => $template_file,
2014 variables => $print_form,
2015 variable_content_types => {
2016 longdescription => 'html',
2017 partnotes => 'html',
2022 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2028 sub get_files_for_email_dialog {
2031 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2033 return %files if !$::instance_conf->get_doc_storage;
2035 if ($self->order->id) {
2036 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2037 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2038 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2039 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2043 uniq_by { $_->{id} }
2045 +{ id => $_->part->id,
2046 partnumber => $_->part->partnumber }
2047 } @{$self->order->items_sorted};
2049 foreach my $part (@parts) {
2050 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2051 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2054 foreach my $key (keys %files) {
2055 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2062 my ($self, $action) = @_;
2064 return '' if none { lc($action)} qw(add edit);
2065 return $self->type_data->text($action);
2068 sub get_item_cvpartnumber {
2069 my ($self, $item) = @_;
2071 return if !$self->search_cvpartnumber;
2072 return if !$self->order->customervendor;
2074 if ($self->cv eq 'vendor') {
2075 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2076 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2077 } elsif ($self->cv eq 'customer') {
2078 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2079 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2083 sub get_part_texts {
2084 my ($part_or_id, $language_or_id, %defaults) = @_;
2086 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2087 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2089 description => $defaults{description} // $part->description,
2090 longdescription => $defaults{longdescription} // $part->notes,
2093 return $texts unless $language_id;
2095 my $translation = SL::DB::Manager::Translation->get_first(
2097 parts_id => $part->id,
2098 language_id => $language_id,
2101 $texts->{description} = $translation->translation if $translation && $translation->translation;
2102 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2108 return $_[0]->type_data->nr_key;
2111 sub save_and_redirect_to {
2112 my ($self, %params) = @_;
2114 my $errors = $self->save();
2116 if (scalar @{ $errors }) {
2117 $self->js->flash('error', $_) foreach @{ $errors };
2118 return $self->js->render();
2121 flash_later('info', $self->type_data->text("saved"));
2123 $self->redirect_to(%params, id => $self->order->id);
2127 my ($self, $addition) = @_;
2129 my $number_type = $self->nr_key;
2130 my $snumbers = $number_type . '_' . $self->order->$number_type;
2132 SL::DB::History->new(
2133 trans_id => $self->order->id,
2134 employee_id => SL::DB::Manager::Employee->current->id,
2135 what_done => $self->order->type,
2136 snumbers => $snumbers,
2137 addition => $addition,
2141 sub store_pdf_to_webdav_and_filemanagement {
2142 my($order, $content, $filename) = @_;
2146 # copy file to webdav folder
2147 if ($order->number && $::instance_conf->get_webdav_documents) {
2148 my $webdav = SL::Webdav->new(
2149 type => $order->type,
2150 number => $order->number,
2152 my $webdav_file = SL::Webdav::File->new(
2154 filename => $filename,
2157 $webdav_file->store(data => \$content);
2160 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2163 if ($order->id && $::instance_conf->get_doc_storage) {
2165 SL::File->save(object_id => $order->id,
2166 object_type => $order->type,
2167 mime_type => 'application/pdf',
2168 source => 'created',
2169 file_type => 'document',
2170 file_name => $filename,
2171 file_contents => $content);
2174 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2181 sub calculate_stock_in_out_from_stock_info {
2182 my ($self, $unit, $stock_info) = @_;
2184 return "" if !$unit;
2186 my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
2188 my $sum = sum0 map {
2189 $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
2192 my $content = _format_number($sum, 2) . ' ' . $unit;
2197 sub calculate_stock_in_out {
2198 my ($self, $item, $stock_info) = @_;
2200 return "" if !$item->part || !$item->part->unit || !$item->unit;
2202 my $sum = sum0 map {
2203 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2204 } $item->delivery_order_stock_entries;
2206 my $content = _format_number($sum, 2);
2211 sub init_type_data {
2212 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2215 sub init_valid_types {
2216 $_[0]->type_data->valid_types;
2227 SL::Controller::Order - controller for orders
2231 This is a new form to enter orders, completely rewritten with the use
2232 of controller and java script techniques.
2234 The aim is to provide the user a better experience and a faster workflow. Also
2235 the code should be more readable, more reliable and better to maintain.
2243 One input row, so that input happens every time at the same place.
2247 Use of pickers where possible.
2251 Possibility to enter more than one item at once.
2255 Item list in a scrollable area, so that the workflow buttons stay at
2260 Reordering item rows with drag and drop is possible. Sorting item rows is
2261 possible (by partnumber, description, qty, sellprice and discount for now).
2265 No C<update> is necessary. All entries and calculations are managed
2266 with ajax-calls and the page only reloads on C<save>.
2270 User can see changes immediately, because of the use of java script
2281 =item * C<SL/Controller/Order.pm>
2285 =item * C<template/webpages/delivery_order/form.html>
2289 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2291 Main tab for basic_data.
2293 This is the only tab here for now. "linked records" and "webdav" tabs are
2294 reused from generic code.
2298 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2300 For displaying information on business type
2302 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2304 The input line for items
2306 =item * C<template/webpages/delivery_order/tabs/_row.html>
2308 One row for already entered items
2310 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2312 Displaying tax information
2316 =item * C<js/kivi.DeliveryOrder.js>
2318 java script functions
2328 =item * price sources: little symbols showing better price / better discount
2330 =item * select units in input row?
2332 =item * check for direct delivery (workflow sales order -> purchase order)
2334 =item * access rights
2336 =item * display weights
2340 =item * optional client/user behaviour
2342 (transactions has to be set - department has to be set -
2343 force project if enabled in client config - transport cost reminder)
2347 =head1 KNOWN BUGS AND CAVEATS
2353 Customer discount is not displayed as a valid discount in price source popup
2354 (this might be a bug in price sources)
2356 (I cannot reproduce this (Bernd))
2360 No indication that <shift>-up/down expands/collapses second row.
2364 Inline creation of parts is not currently supported
2368 Table header is not sticky in the scrolling area.
2372 Sorting does not include C<position>, neither does reordering.
2374 This behavior was implemented intentionally. But we can discuss, which behavior
2375 should be implemented.
2379 =head1 To discuss / Nice to have
2385 How to expand/collapse second row. Now it can be done clicking the icon or
2390 Possibility to select PriceSources in input row?
2394 This controller uses a (changed) copy of the template for the PriceSource
2395 dialog. Maybe there could be used one code source.
2399 Rounding-differences between this controller (PriceTaxCalculator) and the old
2400 form. This is not only a problem here, but also in all parts using the PTC.
2401 There exists a ticket and a patch. This patch should be testet.
2405 An indicator, if the actual inputs are saved (like in an
2406 editor or on text processing application).
2410 A warning when leaving the page without saveing unchanged inputs.
2417 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>